├── .gitignore ├── .rspec ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── mobiledoc │ ├── atoms │ │ └── unknown.rb │ ├── cards │ │ ├── image.rb │ │ └── unknown.rb │ ├── error.rb │ ├── renderers │ │ ├── 0.2.rb │ │ └── 0.3.rb │ └── utils │ │ ├── marker_types.rb │ │ ├── section_types.rb │ │ └── tag_names.rb ├── mobiledoc_html_renderer.rb └── mobiledoc_html_renderer │ └── version.rb ├── mobiledoc-html-renderer.gemspec └── spec ├── 0.2_spec.rb ├── 0.3_spec.rb ├── mobiledoc_html_renderer_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1.5 4 | before_install: gem install bundler -v 1.10.6 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in mobiledoc-html-renderer.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Justin Giancola 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mobiledoc HTML Renderer for Ruby 2 | 3 | This is an HTML renderer for the [Mobiledoc format](https://github.com/bustlelabs/mobiledoc-kit/blob/master/MOBILEDOC.md) used by [Mobiledoc-Kit](https://github.com/bustlelabs/mobiledoc-kit). 4 | 5 | To learn more about Mobiledoc cards and renderers, see the **[Mobiledoc Cards docs](https://github.com/bustlelabs/mobiledoc-kit/blob/master/CARDS.md)** 6 | 7 | The implementation is based closely on https://github.com/bustlelabs/mobiledoc-html-renderer (kinda sorta a port to Ruby). 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'mobiledoc_html_renderer' 15 | ``` 16 | 17 | And then execute: 18 | 19 | $ bundle 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install mobiledoc_html_renderer 24 | 25 | ## Usage 26 | 27 | ```ruby 28 | require 'mobiledoc_html_renderer' 29 | 30 | mobiledoc = { 31 | "version" => "0.2.0", 32 | "sections" => [ 33 | [ # markers 34 | ['B'] 35 | ], 36 | [ # sections 37 | [1, 'P', [ # array of markups 38 | # markup 39 | [ 40 | [0], # open markers (by index) 41 | 0, # close count 42 | 'hello world' 43 | ] 44 | ] 45 | ] 46 | ] 47 | } 48 | 49 | renderer = Mobiledoc::HTMLRenderer.new(cards: []) 50 | renderer.render(mobiledoc) # "

hello world

" 51 | ``` 52 | 53 | ## Development 54 | 55 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 56 | 57 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 58 | 59 | ## Contributing 60 | 61 | Bug reports and pull requests are welcome on GitHub at https://github.com/elucid/mobiledoc-html-renderer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct. 62 | 63 | 64 | ## License 65 | 66 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 67 | 68 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "mobiledoc/html/renderer" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /lib/mobiledoc/atoms/unknown.rb: -------------------------------------------------------------------------------- 1 | require "mobiledoc/error" 2 | 3 | module Mobiledoc 4 | module UnknownAtom 5 | module_function 6 | 7 | def type 8 | 'html' 9 | end 10 | 11 | def render(env, value, payload, options) 12 | name = env[:name] 13 | 14 | raise Mobiledoc::Error.new(%Q[Atom "#{name}" not found]) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/mobiledoc/cards/image.rb: -------------------------------------------------------------------------------- 1 | module Mobiledoc 2 | module ImageCard 3 | module_function 4 | 5 | def name 6 | 'image-card' 7 | end 8 | 9 | def type 10 | 'html' 11 | end 12 | 13 | def render(env, payload, options) 14 | if payload['src'] 15 | %Q[] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/mobiledoc/cards/unknown.rb: -------------------------------------------------------------------------------- 1 | require "mobiledoc/error" 2 | 3 | module Mobiledoc 4 | module UnknownCard 5 | module_function 6 | 7 | def type 8 | 'html' 9 | end 10 | 11 | def render(env, payload, options) 12 | name = env[:name] 13 | 14 | raise Mobiledoc::Error.new(%Q[Card "#{name}" not found]) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/mobiledoc/error.rb: -------------------------------------------------------------------------------- 1 | module Mobiledoc 2 | class Error < StandardError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/mobiledoc/renderers/0.2.rb: -------------------------------------------------------------------------------- 1 | require "nokogiri" 2 | require "mobiledoc/utils/section_types" 3 | require "mobiledoc/utils/tag_names" 4 | require "mobiledoc/cards/image" 5 | require "mobiledoc/error" 6 | 7 | module Mobiledoc 8 | class Renderer_0_2 9 | MOBILEDOC_VERSIONS = ['0.2.0'] 10 | 11 | include Mobiledoc::Utils::SectionTypes 12 | include Mobiledoc::Utils::TagNames 13 | 14 | attr_accessor :root, :marker_types, :sections, :doc, :cards, :card_options, :unknown_card_handler 15 | 16 | def initialize(mobiledoc, state) 17 | version, section_data = *mobiledoc.values_at('version', 'sections') 18 | validate_version(version) 19 | 20 | self.marker_types, self.sections = *section_data 21 | 22 | self.doc = Nokogiri::HTML.fragment('') 23 | self.root = create_document_fragment 24 | self.cards = state[:cards] 25 | self.card_options = state[:card_options] 26 | self.unknown_card_handler = state[:unknown_card_handler] 27 | end 28 | 29 | def validate_version(version) 30 | unless self.class::MOBILEDOC_VERSIONS.to_a.include? version 31 | raise Mobiledoc::Error.new(%Q[Unexpected Mobiledoc version "#{version}"]) 32 | end 33 | end 34 | 35 | def render 36 | root = create_document_fragment 37 | 38 | sections.each do |section| 39 | rendered = render_section(section) 40 | 41 | if rendered 42 | append_child(root, rendered) 43 | end 44 | end 45 | 46 | root.to_html(save_with: 0).gsub(' ', '  ') 47 | end 48 | 49 | def create_document_fragment 50 | create_element('div') 51 | end 52 | 53 | def create_element(tag_name) 54 | tag_name = normalize_tag_name(tag_name) 55 | Nokogiri::XML::Node.new(tag_name, doc) 56 | end 57 | 58 | def set_attribute(element, prop_name, prop_value) 59 | element.set_attribute(prop_name, prop_value) 60 | end 61 | 62 | def create_text_node(text) 63 | Nokogiri::XML::Text.new(text, doc) 64 | end 65 | 66 | def create_element_from_marker_type(tag_name='', attributes=[]) 67 | element = create_element(tag_name) 68 | 69 | attributes.each_slice(2) do |prop_name, prop_value| 70 | set_attribute(element, prop_name, prop_value) 71 | end 72 | 73 | element 74 | end 75 | 76 | def append_child(target, child) 77 | target.add_child(child) 78 | end 79 | 80 | def render_section(section) 81 | type = section.first 82 | case type 83 | when MARKUP_SECTION_TYPE 84 | render_markup_section(*section) 85 | when IMAGE_SECTION_TYPE 86 | render_image_section(*section) 87 | when LIST_SECTION_TYPE 88 | render_list_section(*section) 89 | when CARD_SECTION_TYPE 90 | render_card_section(*section) 91 | end 92 | end 93 | 94 | def render_markup_section(type, tag_name, markers) 95 | return unless valid_section_tag_name?(tag_name, MARKUP_SECTION_TYPE) 96 | 97 | element = create_element(tag_name) 98 | _render_markers_on_element(element, markers) 99 | element 100 | end 101 | 102 | def render_image_section(type, url) 103 | element = create_element('img') 104 | set_attribute(element, 'src', url) 105 | element 106 | end 107 | 108 | def render_list_section(type, tag_name, items) 109 | return unless valid_section_tag_name?(tag_name, LIST_SECTION_TYPE) 110 | 111 | element = create_element(tag_name) 112 | items.each do |item| 113 | append_child(element, render_list_item(item)) 114 | end 115 | 116 | element 117 | end 118 | 119 | def render_list_item(markers) 120 | element = create_element('li') 121 | _render_markers_on_element(element, markers) 122 | element 123 | end 124 | 125 | def render_card_section(type, name, payload={}) 126 | card = find_card(name) 127 | 128 | _render_card_section(card, name, payload) 129 | end 130 | 131 | def _render_card_section(card, name, payload) 132 | card_wrapper = _create_card_element 133 | card_arg = _create_card_argument(name, payload) 134 | rendered = card.render(*card_arg) 135 | 136 | _validate_card_render(rendered, card.name) 137 | 138 | if rendered 139 | append_child(card_wrapper, rendered) 140 | end 141 | 142 | card_wrapper 143 | end 144 | 145 | def find_card(name) 146 | card = cards.find { |c| c.name == name } 147 | 148 | case 149 | when card 150 | card 151 | when ImageCard.name == name 152 | ImageCard 153 | else 154 | unknown_card_handler 155 | end 156 | end 157 | 158 | def _create_card_element 159 | create_element('div') 160 | end 161 | 162 | def _create_card_argument(card_name, payload={}) 163 | env = { 164 | name: card_name 165 | } 166 | 167 | [ env, payload, card_options ] 168 | end 169 | 170 | def _validate_card_render(rendered, card_name) 171 | return unless rendered 172 | 173 | unless rendered.is_a?(String) 174 | raise Mobiledoc::Error.new(%Q[Card "#{card_name}" must render html, but result was #{rendered.class}"]); 175 | end 176 | end 177 | 178 | def _render_markers_on_element(element, markers) 179 | elements = [element] 180 | current_element = element 181 | 182 | markers.each do |marker| 183 | open_types, close_count, text = *marker 184 | 185 | open_types.each do |open_type| 186 | marker_type = marker_types[open_type] 187 | tag_name = marker_type.first 188 | 189 | if valid_marker_type?(tag_name) 190 | opened_element = create_element_from_marker_type(*marker_type) 191 | append_child(current_element, opened_element) 192 | elements.push(opened_element) 193 | current_element = opened_element 194 | else 195 | close_count -= 1 196 | end 197 | end 198 | 199 | append_child(current_element, create_text_node(text)) 200 | 201 | close_count.times do 202 | elements.pop 203 | current_element = elements.last 204 | end 205 | end 206 | end 207 | 208 | def valid_section_tag_name?(tag_name, section_type) 209 | tag_name = normalize_tag_name(tag_name) 210 | 211 | case section_type 212 | when MARKUP_SECTION_TYPE 213 | MARKUP_SECTION_TAG_NAMES.include?(tag_name) 214 | when LIST_SECTION_TYPE 215 | LIST_SECTION_TAG_NAMES.include?(tag_name) 216 | else 217 | raise Mobiledoc::Error.new(%Q[Cannot validate tag_name for unknown section type "#{section_type}"]) 218 | end 219 | end 220 | 221 | def valid_marker_type?(type) 222 | type = normalize_tag_name(type) 223 | 224 | MARKUP_TYPES.include?(type) 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /lib/mobiledoc/renderers/0.3.rb: -------------------------------------------------------------------------------- 1 | require "mobiledoc/renderers/0.2" 2 | require 'mobiledoc/utils/marker_types' 3 | require "mobiledoc/error" 4 | 5 | module Mobiledoc 6 | class Renderer_0_3 < Renderer_0_2 7 | MOBILEDOC_VERSIONS = ['0.3.0', '0.3.1', '0.3.2'] 8 | 9 | include Mobiledoc::Utils::MarkerTypes 10 | 11 | attr_accessor :atom_types, :card_types, :atoms, :unknown_atom_handler 12 | 13 | def initialize(mobiledoc, state) 14 | version, sections, atom_types, card_types, marker_types = *mobiledoc.values_at('version', 'sections', 'atoms', 'cards', 'markups') 15 | validate_version(version) 16 | 17 | self.doc = Nokogiri::HTML.fragment('') 18 | self.root = create_document_fragment 19 | self.sections = sections 20 | self.atom_types = atom_types 21 | self.card_types = card_types 22 | self.marker_types = marker_types 23 | self.cards = state[:cards] 24 | self.atoms = state[:atoms] 25 | self.card_options = state[:card_options] 26 | self.unknown_card_handler = state[:unknown_card_handler] 27 | self.unknown_atom_handler = state[:unknown_atom_handler] 28 | end 29 | 30 | def render_markup_section(type, tag_name, markers, attributes = []) 31 | return unless valid_section_tag_name?(tag_name, MARKUP_SECTION_TYPE) 32 | 33 | element = create_element(tag_name) 34 | attributes.each_slice(2) do |prop_name, prop_value| 35 | set_attribute(element, prop_name, prop_value) 36 | end 37 | _render_markers_on_element(element, markers) 38 | element 39 | end 40 | 41 | def render_card_section(type, index) 42 | card, name, payload = _find_card_by_index(index) 43 | 44 | _render_card_section(card, name, payload) 45 | end 46 | 47 | def _find_card_by_index(index) 48 | card_type = card_types[index] 49 | 50 | unless card_type 51 | raise Mobiledoc::Error.new("No card definition found at index #{index}") 52 | end 53 | 54 | name, payload = *card_type 55 | card = find_card(name) 56 | 57 | [ card, name, payload ] 58 | end 59 | 60 | def _render_markers_on_element(element, markers) 61 | elements = [element] 62 | current_element = element 63 | 64 | markers.each do |marker| 65 | type, open_types, close_count, value = *marker 66 | 67 | open_types.each do |open_type| 68 | marker_type = marker_types[open_type] 69 | tag_name = marker_type.first 70 | 71 | if valid_marker_type?(tag_name) 72 | opened_element = create_element_from_marker_type(*marker_type) 73 | append_child(current_element, opened_element) 74 | elements.push(opened_element) 75 | current_element = opened_element 76 | else 77 | close_count -= 1 78 | end 79 | end 80 | 81 | case type 82 | when MARKUP_MARKER_TYPE 83 | append_child(current_element, create_text_node(value)) 84 | when ATOM_MARKER_TYPE 85 | append_child(current_element, _render_atom(value)) 86 | else 87 | raise Mobiledoc::Error.new("Unknown markup type (#{type})"); 88 | end 89 | 90 | close_count.times do 91 | elements.pop 92 | current_element = elements.last 93 | end 94 | end 95 | end 96 | 97 | def find_atom(name) 98 | atom = atoms.find { |a| a.name == name } 99 | 100 | atom || unknown_atom_handler 101 | end 102 | 103 | def _render_atom(index) 104 | atom, name, value, payload = _find_atom_by_index(index) 105 | atom_arg = _create_atom_argument(atom, name, value, payload) 106 | rendered = atom.render(*atom_arg) 107 | 108 | _validate_atom_render(rendered, atom.name) 109 | 110 | rendered || create_text_node('') 111 | end 112 | 113 | def _find_atom_by_index(index) 114 | atom_type = atom_types[index] 115 | 116 | unless atom_type 117 | raise Mobiledoc::Error.new("No atom definition found at index #{index}") 118 | end 119 | 120 | name, value, payload = *atom_type 121 | atom = find_atom(name) 122 | 123 | [ atom, name, value, payload ] 124 | end 125 | 126 | def _create_atom_argument(atom, atom_name, value, payload={}) 127 | env = { 128 | name: atom_name 129 | } 130 | 131 | [ env, value, payload, card_options ] 132 | end 133 | 134 | def _validate_atom_render(rendered, atom_name) 135 | return unless rendered 136 | 137 | unless rendered.is_a?(String) 138 | raise Mobiledoc::Error.new(%Q[Atom "#{atom_name}" must render html, but result was #{rendered.class}"]); 139 | end 140 | end 141 | 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/mobiledoc/utils/marker_types.rb: -------------------------------------------------------------------------------- 1 | module Mobiledoc 2 | module Utils 3 | module MarkerTypes 4 | MARKUP_MARKER_TYPE = 0 5 | ATOM_MARKER_TYPE = 1 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/mobiledoc/utils/section_types.rb: -------------------------------------------------------------------------------- 1 | module Mobiledoc 2 | module Utils 3 | module SectionTypes 4 | MARKUP_SECTION_TYPE = 1 5 | IMAGE_SECTION_TYPE = 2 6 | LIST_SECTION_TYPE = 3 7 | CARD_SECTION_TYPE = 10 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/mobiledoc/utils/tag_names.rb: -------------------------------------------------------------------------------- 1 | module Mobiledoc 2 | module Utils 3 | module TagNames 4 | MARKUP_SECTION_TAG_NAMES = [ 5 | 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pull-quote', 'aside' 6 | ] 7 | 8 | LIST_SECTION_TAG_NAMES = [ 9 | 'ul', 'ol' 10 | ] 11 | 12 | MARKUP_TYPES = [ 13 | 'b', 'i', 'strong', 'em', 'a', 'u', 'sub', 'sup', 's', 'code' 14 | ] 15 | 16 | def normalize_tag_name(name) 17 | name.downcase 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/mobiledoc_html_renderer.rb: -------------------------------------------------------------------------------- 1 | require "mobiledoc_html_renderer/version" 2 | require "mobiledoc/renderers/0.2" 3 | require "mobiledoc/renderers/0.3" 4 | require "mobiledoc/cards/unknown" 5 | require "mobiledoc/atoms/unknown" 6 | require "mobiledoc/error" 7 | 8 | module Mobiledoc 9 | class HTMLRenderer 10 | attr_accessor :state 11 | 12 | def initialize(options={}) 13 | cards = options[:cards] || [] 14 | validate_cards(cards) 15 | 16 | atoms = options[:atoms] || [] 17 | validate_atoms(atoms) 18 | 19 | card_options = options[:card_options] || {} 20 | 21 | unknown_card_handler = options[:unknown_card_handler] || UnknownCard 22 | unknown_atom_handler = options[:unknown_atom_handler] || UnknownAtom 23 | 24 | self.state = { 25 | cards: cards, 26 | atoms: atoms, 27 | card_options: card_options, 28 | unknown_card_handler: unknown_card_handler, 29 | unknown_atom_handler: unknown_atom_handler 30 | } 31 | end 32 | 33 | def validate_cards(cards) 34 | unless cards.is_a?(Array) 35 | raise Mobiledoc::Error.new("`cards` must be passed as an array") 36 | end 37 | 38 | cards.each do |card| 39 | unless card.type == 'html' 40 | raise Mobiledoc::Error.new(%Q[Card "#{card.name}" must be of type "html", was "#{card.type}"]) 41 | end 42 | 43 | unless card.respond_to?(:render) 44 | raise Mobiledoc::Error.new(%Q[Card "#{card.name}" must define \`render\`]) 45 | end 46 | end 47 | end 48 | 49 | def validate_atoms(atoms) 50 | unless atoms.is_a?(Array) 51 | raise Mobiledoc::Error.new("`atoms` must be passed as an array") 52 | end 53 | 54 | atoms.each do |atom| 55 | unless atom.type == 'html' 56 | raise Mobiledoc::Error.new(%Q[Atom "#{atom.name}" must be of type "html", was "#{atom.type}"]) 57 | end 58 | 59 | unless atom.respond_to?(:render) 60 | raise Mobiledoc::Error.new(%Q[Atom "#{atom.name}" must define \`render\`]) 61 | end 62 | end 63 | end 64 | 65 | def render(mobiledoc) 66 | version = mobiledoc['version'] 67 | 68 | case version 69 | when '0.2.0' 70 | Renderer_0_2.new(mobiledoc, state).render 71 | when '0.3.0', '0.3.1', '0.3.2', nil 72 | Renderer_0_3.new(mobiledoc, state).render 73 | else 74 | raise Mobiledoc::Error.new(%Q[Unexpected Mobiledoc version "#{version}"]) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/mobiledoc_html_renderer/version.rb: -------------------------------------------------------------------------------- 1 | module Mobiledoc 2 | class HTMLRenderer 3 | VERSION = "1.0.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /mobiledoc-html-renderer.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'mobiledoc_html_renderer/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "mobiledoc-html-renderer" 8 | spec.version = Mobiledoc::HTMLRenderer::VERSION 9 | spec.authors = ["Justin Giancola"] 10 | spec.email = ["justin.giancola@gmail.com"] 11 | 12 | spec.summary = %q{MobileDoc HTML Renderer for Ruby} 13 | spec.homepage = "https://github.com/elucid/mobiledoc-html-renderer" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "> 1.10" 22 | spec.add_development_dependency "rake", "~> 10.0" 23 | spec.add_development_dependency "rspec" 24 | spec.add_development_dependency "pry" 25 | 26 | spec.add_dependency "nokogiri", "~> 1.6" 27 | end 28 | -------------------------------------------------------------------------------- /spec/0.2_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mobiledoc/utils/section_types' 3 | require 'mobiledoc/cards/image' 4 | 5 | module ZeroTwoZero 6 | include Mobiledoc::Utils::SectionTypes 7 | 8 | MOBILEDOC_VERSION = '0.2.0' 9 | 10 | describe Mobiledoc::HTMLRenderer, "(#{MOBILEDOC_VERSION})" do 11 | 12 | def render(mobiledoc) 13 | described_class.new.render(mobiledoc) 14 | end 15 | 16 | let(:data_uri) { "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=" } 17 | 18 | it 'renders an empty mobiledoc' do 19 | mobiledoc = { 20 | 'version' => MOBILEDOC_VERSION, 21 | 'sections' => [ 22 | [], # markers 23 | [] # sections 24 | ] 25 | } 26 | 27 | rendered = render(mobiledoc) 28 | 29 | expect(rendered).to eq('
') 30 | end 31 | 32 | it 'renders a mobiledoc without markups' do 33 | mobiledoc = { 34 | 'version' => MOBILEDOC_VERSION, 35 | 'sections' => [ 36 | [], # markers 37 | [ 38 | [MARKUP_SECTION_TYPE, 'P', [ 39 | [[], 0, 'hello world']] 40 | ] 41 | ] # sections 42 | ] 43 | } 44 | 45 | rendered = render(mobiledoc) 46 | 47 | expect(rendered).to eq('

hello world

') 48 | end 49 | 50 | it 'renders a mobiledoc with simple (no attributes) markup' do 51 | mobiledoc = { 52 | 'version' => MOBILEDOC_VERSION, 53 | 'sections' => [ 54 | [ # markers 55 | ['B'], 56 | ], 57 | [ # sections 58 | [MARKUP_SECTION_TYPE, 'P', [ 59 | [[0], 1, 'hello world']] 60 | ] 61 | ] 62 | ] 63 | } 64 | 65 | rendered = render(mobiledoc) 66 | 67 | expect(rendered).to eq('

hello world

') 68 | end 69 | 70 | it 'renders a mobiledoc with complex (has attributes) markup' do 71 | mobiledoc = { 72 | 'version' => MOBILEDOC_VERSION, 73 | 'sections' => [ 74 | [ # markers 75 | ['A', ['href', 'http://google.com']], 76 | ], 77 | [ # sections 78 | [MARKUP_SECTION_TYPE, 'P', [ 79 | [[0], 1, 'hello world'] 80 | ]] 81 | ] 82 | ] 83 | } 84 | 85 | rendered = render(mobiledoc) 86 | 87 | expect(rendered).to eq('

hello world

') 88 | end 89 | 90 | it 'renders a mobiledoc with multiple markups in a section' do 91 | mobiledoc = { 92 | 'version' => MOBILEDOC_VERSION, 93 | 'sections' => [ 94 | [ # markers 95 | ['B'], 96 | ['I'] 97 | ], 98 | [ # sections 99 | [MARKUP_SECTION_TYPE, 'P', [ 100 | [[0], 0, 'hello '], # b 101 | [[1], 0, 'brave '], # b + i 102 | [[], 1, 'new '], # close i 103 | [[], 1, 'world'] # close b 104 | ]] 105 | ] 106 | ] 107 | } 108 | 109 | rendered = render(mobiledoc) 110 | 111 | expect(rendered).to eq('

hello brave new world

') 112 | end 113 | 114 | it 'renders a mobiledoc with image section' do 115 | mobiledoc = { 116 | 'version' => MOBILEDOC_VERSION, 117 | 'sections' => [ 118 | [], # markers 119 | [ # sections 120 | [IMAGE_SECTION_TYPE, data_uri] 121 | ] 122 | ] 123 | } 124 | 125 | rendered = render(mobiledoc) 126 | 127 | expect(rendered).to eq(%Q[
]) 128 | end 129 | 130 | it 'renders a mobiledoc with built-in image card' do 131 | card_name = Mobiledoc::ImageCard.name 132 | 133 | payload = { 'src' => data_uri } 134 | 135 | mobiledoc = { 136 | 'version' => MOBILEDOC_VERSION, 137 | 'sections' => [ 138 | [], # markers 139 | [ # sections 140 | [CARD_SECTION_TYPE, card_name, payload] 141 | ] 142 | ] 143 | } 144 | 145 | rendered = render(mobiledoc) 146 | 147 | expect(rendered).to eq(%Q[
]) 148 | end 149 | 150 | it 'render mobiledoc with list section and list items' do 151 | mobiledoc = { 152 | 'version' => MOBILEDOC_VERSION, 153 | 'sections' => [ 154 | [], # markers 155 | [ # sections 156 | [LIST_SECTION_TYPE, 'ul', [ 157 | [[[], 0, 'first item']], 158 | [[[], 0, 'second item']] 159 | ]] 160 | ] 161 | ] 162 | } 163 | 164 | rendered = render(mobiledoc) 165 | 166 | expect(rendered).to eq('
') 167 | end 168 | 169 | it 'renders a mobiledoc with card section' do 170 | card_name = 'title-card' 171 | expected_payload = {} 172 | expected_options = {} 173 | 174 | title_card = Module.new do 175 | module_function 176 | 177 | def name 178 | 'title-card' 179 | end 180 | 181 | def type 182 | 'html' 183 | end 184 | 185 | def render(env, payload, options) 186 | end 187 | end 188 | 189 | mobiledoc = { 190 | 'version' => MOBILEDOC_VERSION, 191 | 'sections' => [ 192 | [], # markers 193 | [ # sections 194 | [CARD_SECTION_TYPE, card_name, expected_payload] 195 | ] 196 | ] 197 | } 198 | 199 | expect(title_card).to receive(:render).with({name: card_name}, expected_payload, expected_options).and_return("Howdy friend") 200 | 201 | renderer = Mobiledoc::HTMLRenderer.new(cards: [title_card], card_options: expected_options) 202 | rendered = renderer.render(mobiledoc) 203 | 204 | expect(rendered).to eq('
Howdy friend
') 205 | end 206 | 207 | it 'throws when given invalid card type' do 208 | bad_card = Module.new do 209 | module_function 210 | 211 | def name 212 | 'bad' 213 | end 214 | 215 | def type 216 | 'other' 217 | end 218 | 219 | def render(env, payload, options) 220 | end 221 | end 222 | 223 | expect{ Mobiledoc::HTMLRenderer.new(cards: [bad_card]) }.to raise_error(%Q[Card "bad" must be of type "html", was "other"]) 224 | end 225 | 226 | it 'throws when given card without `render`' do 227 | bad_card = Module.new do 228 | module_function 229 | 230 | def name 231 | 'bad' 232 | end 233 | 234 | def type 235 | 'html' 236 | end 237 | end 238 | 239 | expect{ Mobiledoc::HTMLRenderer.new(cards: [bad_card]) }.to raise_error(%Q[Card "bad" must define `render`]) 240 | end 241 | 242 | it 'throws if card render returns invalid result' do 243 | bad_card = Module.new do 244 | module_function 245 | 246 | def name 247 | 'bad' 248 | end 249 | 250 | def type 251 | 'html' 252 | end 253 | 254 | def render(env, payload, options) 255 | Object.new 256 | end 257 | end 258 | 259 | mobiledoc = { 260 | 'version' => MOBILEDOC_VERSION, 261 | 'sections' => [ 262 | [], # markers 263 | [ # sections 264 | [CARD_SECTION_TYPE, 'bad'] 265 | ] 266 | ] 267 | } 268 | 269 | renderer = Mobiledoc::HTMLRenderer.new(cards: [bad_card]) 270 | 271 | expect{ renderer.render(mobiledoc) }.to raise_error(/Card "bad" must render html/) 272 | end 273 | 274 | it 'card may render nothing' do 275 | card = Module.new do 276 | module_function 277 | 278 | def name 279 | 'ok' 280 | end 281 | 282 | def type 283 | 'html' 284 | end 285 | 286 | def render(env, payload, options) 287 | end 288 | end 289 | 290 | mobiledoc = { 291 | 'version' => MOBILEDOC_VERSION, 292 | 'sections' => [ 293 | [], # markers 294 | [ # sections 295 | [CARD_SECTION_TYPE, 'ok'] 296 | ] 297 | ] 298 | } 299 | 300 | renderer = Mobiledoc::HTMLRenderer.new(cards: [card]) 301 | 302 | expect{ renderer.render(mobiledoc) }.to_not raise_error 303 | end 304 | 305 | it 'rendering nested mobiledocs in cards' do 306 | card = Module.new do 307 | module_function 308 | 309 | def name 310 | 'nested-card' 311 | end 312 | 313 | def type 314 | 'html' 315 | end 316 | 317 | def render(env, payload, options) 318 | options[:renderer].render(payload['mobiledoc']) 319 | end 320 | end 321 | 322 | inner_mobiledoc = { 323 | 'version' => MOBILEDOC_VERSION, 324 | 'sections' => [ 325 | [], # markers 326 | [ # sections 327 | [MARKUP_SECTION_TYPE, 'P', [ 328 | [[], 0, 'hello world']] 329 | ] 330 | ] 331 | ] 332 | } 333 | 334 | mobiledoc = { 335 | 'version' => MOBILEDOC_VERSION, 336 | 'sections' => [ 337 | [], # markers 338 | [ # sections 339 | [CARD_SECTION_TYPE, 'nested-card', { 'mobiledoc' => inner_mobiledoc }] 340 | ] 341 | ] 342 | } 343 | 344 | renderer = Mobiledoc::HTMLRenderer.new(cards: [card], card_options: { renderer: self }) 345 | 346 | rendered = renderer.render(mobiledoc) 347 | 348 | expect(rendered).to eq('

hello world

') 349 | end 350 | 351 | it 'rendering unknown card without unknown_card_handler throws' do 352 | card_name = 'missing-card' 353 | 354 | mobiledoc = { 355 | 'version' => MOBILEDOC_VERSION, 356 | 'sections' => [ 357 | [], # markers 358 | [ # sections 359 | [CARD_SECTION_TYPE, card_name] 360 | ] 361 | ] 362 | } 363 | 364 | renderer = Mobiledoc::HTMLRenderer.new(cards: []) 365 | 366 | expect{ renderer.render(mobiledoc) }.to raise_error(%Q[Card "missing-card" not found]) 367 | end 368 | 369 | it 'rendering unknown card uses unknown_card_handler' do 370 | card_name = 'missing-card' 371 | expected_payload = {} 372 | expected_options = {} 373 | 374 | unknown_card_handler = Module.new do 375 | module_function 376 | 377 | def type 378 | 'html' 379 | end 380 | 381 | def render(env, payload, options) 382 | end 383 | end 384 | 385 | mobiledoc = { 386 | 'version' => MOBILEDOC_VERSION, 387 | 'sections' => [ 388 | [], # markers 389 | [ # sections 390 | [CARD_SECTION_TYPE, card_name, expected_payload] 391 | ] 392 | ] 393 | } 394 | 395 | expect(unknown_card_handler).to receive(:render).with({name: card_name}, expected_payload, expected_options) 396 | 397 | renderer = Mobiledoc::HTMLRenderer.new(cards: [], card_options: expected_options, unknown_card_handler: unknown_card_handler) 398 | rendered = renderer.render(mobiledoc) 399 | end 400 | 401 | it 'throws if given an object of cards' do 402 | expect{ Mobiledoc::HTMLRenderer.new(cards: {}) }.to raise_exception('`cards` must be passed as an array') 403 | end 404 | 405 | it 'XSS: tag contents are entity escaped' do 406 | xss = "" 407 | 408 | mobiledoc = { 409 | 'version' => MOBILEDOC_VERSION, 410 | 'sections' => [ 411 | [], # markers 412 | [ # sections 413 | [MARKUP_SECTION_TYPE, 'P', [ 414 | [[], 0, xss]] 415 | ] 416 | ] 417 | ] 418 | } 419 | 420 | rendered = render(mobiledoc) 421 | 422 | expect(rendered).to eq("

<script>alert('xx')</script>

") 423 | end 424 | 425 | it 'multiple spaces should preserve whitespace with nbsps' do 426 | space = ' ' 427 | text = [ space * 4, 'some', space * 5, 'text', space * 6].join 428 | 429 | mobiledoc = { 430 | 'version' => MOBILEDOC_VERSION, 431 | 'sections' => [ 432 | [], # markers 433 | [ # sections 434 | [MARKUP_SECTION_TYPE, 'P', [ 435 | [[], 0, text]] 436 | ] 437 | ] 438 | ] 439 | } 440 | 441 | rendered = render(mobiledoc) 442 | 443 | sn = '  ' 444 | expected_text = [ sn * 2, 'some', sn * 2, space, 'text', sn * 3 ].join 445 | 446 | expect(rendered).to eq("

#{expected_text}

") 447 | end 448 | 449 | it 'throws when given an unexpected mobiledoc version' do 450 | mobiledoc = { 451 | 'version' => '0.1.0', 452 | 'sections' => [ 453 | [], [] 454 | ] 455 | } 456 | 457 | expect{ render(mobiledoc) }.to raise_error('Unexpected Mobiledoc version "0.1.0"') 458 | 459 | mobiledoc['version'] = '0.2.1' 460 | 461 | expect{ render(mobiledoc) }.to raise_error('Unexpected Mobiledoc version "0.2.1"') 462 | end 463 | 464 | it 'XSS: unexpected markup and list section tag names are not renderered' do 465 | mobiledoc = { 466 | 'version' => MOBILEDOC_VERSION, 467 | 'sections' => [ 468 | [], 469 | [ 470 | [MARKUP_SECTION_TYPE, 'script', [ 471 | [[], 0, 'alert("markup section XSS")'] 472 | ]], 473 | [LIST_SECTION_TYPE, 'script', [ 474 | [[[], 0, 'alert("list section XSS")']] 475 | ]] 476 | ] 477 | ] 478 | } 479 | 480 | rendered = render(mobiledoc) 481 | 482 | expect(rendered).to_not match(/script/) 483 | end 484 | 485 | it 'XSS: unexpected markup types are not rendered' do 486 | mobiledoc = { 487 | 'version' => MOBILEDOC_VERSION, 488 | 'sections' => [ 489 | [ 490 | ['b'], # valid 491 | ['em'], # valid 492 | ['script'] # invalid 493 | ], 494 | [ 495 | [MARKUP_SECTION_TYPE, 'p', [ 496 | [[0], 0, 'bold text'], 497 | [[1,2], 3, 'alert("markup XSS")'], 498 | [[], 0, 'plain text'] 499 | ]] 500 | ] 501 | ] 502 | } 503 | 504 | rendered = render(mobiledoc) 505 | 506 | expect(rendered).to_not match(/script/) 507 | end 508 | end 509 | end 510 | -------------------------------------------------------------------------------- /spec/0.3_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'mobiledoc/utils/section_types' 3 | require 'mobiledoc/utils/marker_types' 4 | require 'mobiledoc/cards/image' 5 | 6 | module ZeroThreeZero 7 | include Mobiledoc::Utils::SectionTypes 8 | include Mobiledoc::Utils::MarkerTypes 9 | 10 | MOBILEDOC_VERSION = '0.3.0' 11 | 12 | describe Mobiledoc::HTMLRenderer, "(#{MOBILEDOC_VERSION})" do 13 | 14 | def render(mobiledoc) 15 | described_class.new.render(mobiledoc) 16 | end 17 | 18 | let(:data_uri) { "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=" } 19 | 20 | it 'renders an empty mobiledoc' do 21 | mobiledoc = { 22 | 'version' => MOBILEDOC_VERSION, 23 | 'atoms' => [], 24 | 'cards' => [], 25 | 'markups' => [], 26 | 'sections' => [] 27 | } 28 | 29 | rendered = render(mobiledoc) 30 | 31 | expect(rendered).to eq('
') 32 | end 33 | 34 | it 'renders a mobiledoc without markups' do 35 | mobiledoc = { 36 | 'version' => MOBILEDOC_VERSION, 37 | 'atoms' => [], 38 | 'cards' => [], 39 | 'markups' => [], 40 | 'sections' => [ 41 | [MARKUP_SECTION_TYPE, 'P', [ 42 | [MARKUP_MARKER_TYPE, [], 0, 'hello world']] 43 | ] 44 | ] 45 | } 46 | 47 | rendered = render(mobiledoc) 48 | 49 | expect(rendered).to eq('

hello world

') 50 | end 51 | 52 | it 'renders a mobiledoc with simple (no attributes) markup' do 53 | mobiledoc = { 54 | 'version' => MOBILEDOC_VERSION, 55 | 'atoms' => [], 56 | 'cards' => [], 57 | 'markups' => [ 58 | ['B'] 59 | ], 60 | 'sections' => [ 61 | [MARKUP_SECTION_TYPE, 'P', [ 62 | [MARKUP_MARKER_TYPE, [0], 1, 'hello world']] 63 | ] 64 | ] 65 | } 66 | 67 | rendered = render(mobiledoc) 68 | 69 | expect(rendered).to eq('

hello world

') 70 | end 71 | 72 | it 'renders a mobiledoc with complex (has attributes) markup' do 73 | mobiledoc = { 74 | 'version' => MOBILEDOC_VERSION, 75 | 'atoms' => [], 76 | 'cards' => [], 77 | 'markups' => [ 78 | ['A', ['href', 'http://google.com']] 79 | ], 80 | 'sections' => [ 81 | [MARKUP_SECTION_TYPE, 'P', [ 82 | [MARKUP_MARKER_TYPE, [0], 1, 'hello world'] 83 | ]] 84 | ] 85 | } 86 | 87 | rendered = render(mobiledoc) 88 | 89 | expect(rendered).to eq('

hello world

') 90 | end 91 | 92 | it 'renders a mobiledoc with multiple markups in a section' do 93 | mobiledoc = { 94 | 'version' => MOBILEDOC_VERSION, 95 | 'atoms' => [], 96 | 'cards' => [], 97 | 'markups' => [ 98 | ['B'], 99 | ['I'] 100 | ], 101 | 'sections' => [ 102 | [MARKUP_SECTION_TYPE, 'P', [ 103 | [MARKUP_MARKER_TYPE, [0], 0, 'hello '], # b 104 | [MARKUP_MARKER_TYPE, [1], 0, 'brave '], # b + i 105 | [MARKUP_MARKER_TYPE, [], 1, 'new '], # close i 106 | [MARKUP_MARKER_TYPE, [], 1, 'world'] # close b 107 | ]] 108 | ] 109 | } 110 | 111 | rendered = render(mobiledoc) 112 | 113 | expect(rendered).to eq('

hello brave new world

') 114 | end 115 | 116 | it 'renders a mobiledoc with image section' do 117 | mobiledoc = { 118 | 'version' => MOBILEDOC_VERSION, 119 | 'atoms' => [], 120 | 'cards' => [], 121 | 'markups' => [], 122 | 'sections' => [ 123 | [IMAGE_SECTION_TYPE, data_uri] 124 | ] 125 | } 126 | 127 | rendered = render(mobiledoc) 128 | 129 | expect(rendered).to eq(%Q[
]) 130 | end 131 | 132 | it 'renders a mobiledoc with built-in image card' do 133 | card_name = Mobiledoc::ImageCard.name 134 | 135 | payload = { 'src' => data_uri } 136 | 137 | mobiledoc = { 138 | 'version' => MOBILEDOC_VERSION, 139 | 'atoms' => [], 140 | 'cards' => [ 141 | [card_name, payload] 142 | ], 143 | 'markups' => [], 144 | 'sections' => [ 145 | [CARD_SECTION_TYPE, 0] 146 | ] 147 | } 148 | 149 | rendered = render(mobiledoc) 150 | 151 | expect(rendered).to eq(%Q[
]) 152 | end 153 | 154 | it 'render mobiledoc with list section and list items' do 155 | mobiledoc = { 156 | 'version' => MOBILEDOC_VERSION, 157 | 'atoms' => [], 158 | 'cards' => [], 159 | 'markups' => [], 160 | 'sections' => [ 161 | [LIST_SECTION_TYPE, 'ul', [ 162 | [[MARKUP_MARKER_TYPE, [], 0, 'first item']], 163 | [[MARKUP_MARKER_TYPE, [], 0, 'second item']] 164 | ]] 165 | ] 166 | } 167 | 168 | rendered = render(mobiledoc) 169 | 170 | expect(rendered).to eq('
') 171 | end 172 | 173 | it 'renders a mobiledoc with card section' do 174 | card_name = 'title-card' 175 | expected_payload = {} 176 | expected_options = {} 177 | 178 | title_card = Module.new do 179 | module_function 180 | 181 | def name 182 | 'title-card' 183 | end 184 | 185 | def type 186 | 'html' 187 | end 188 | 189 | def render(env, payload, options) 190 | end 191 | end 192 | 193 | mobiledoc = { 194 | 'version' => MOBILEDOC_VERSION, 195 | 'atoms' => [], 196 | 'cards' => [ 197 | [card_name, expected_payload] 198 | ], 199 | 'markups' => [], 200 | 'sections' => [ 201 | [CARD_SECTION_TYPE, 0] 202 | ] 203 | } 204 | 205 | expect(title_card).to receive(:render).with({name: card_name}, expected_payload, expected_options).and_return("Howdy friend") 206 | 207 | renderer = Mobiledoc::HTMLRenderer.new(cards: [title_card], card_options: expected_options) 208 | rendered = renderer.render(mobiledoc) 209 | 210 | expect(rendered).to eq('
Howdy friend
') 211 | end 212 | 213 | it 'throws when given invalid card type' do 214 | bad_card = Module.new do 215 | module_function 216 | 217 | def name 218 | 'bad' 219 | end 220 | 221 | def type 222 | 'other' 223 | end 224 | 225 | def render(env, payload, options) 226 | end 227 | end 228 | 229 | expect{ Mobiledoc::HTMLRenderer.new(cards: [bad_card]) }.to raise_error(%Q[Card "bad" must be of type "html", was "other"]) 230 | end 231 | 232 | it 'throws when given card without `render`' do 233 | bad_card = Module.new do 234 | module_function 235 | 236 | def name 237 | 'bad' 238 | end 239 | 240 | def type 241 | 'html' 242 | end 243 | end 244 | 245 | expect{ Mobiledoc::HTMLRenderer.new(cards: [bad_card]) }.to raise_error(%Q[Card "bad" must define `render`]) 246 | end 247 | 248 | it 'throws if card render returns invalid result' do 249 | bad_card = Module.new do 250 | module_function 251 | 252 | def name 253 | 'bad' 254 | end 255 | 256 | def type 257 | 'html' 258 | end 259 | 260 | def render(env, payload, options) 261 | Object.new 262 | end 263 | end 264 | 265 | mobiledoc = { 266 | 'version' => MOBILEDOC_VERSION, 267 | 'atoms' => [], 268 | 'cards' => [ 269 | [bad_card.name] 270 | ], 271 | 'markups' => [], 272 | 'sections' => [ 273 | [CARD_SECTION_TYPE, 0] 274 | ] 275 | } 276 | 277 | renderer = Mobiledoc::HTMLRenderer.new(cards: [bad_card]) 278 | 279 | expect{ renderer.render(mobiledoc) }.to raise_error(/Card "bad" must render html/) 280 | end 281 | 282 | it 'card may render nothing' do 283 | card = Module.new do 284 | module_function 285 | 286 | def name 287 | 'ok' 288 | end 289 | 290 | def type 291 | 'html' 292 | end 293 | 294 | def render(env, payload, options) 295 | end 296 | end 297 | 298 | mobiledoc = { 299 | 'version' => MOBILEDOC_VERSION, 300 | 'atoms' => [], 301 | 'cards' => [ 302 | [card.name] 303 | ], 304 | 'markups' => [], 305 | 'sections' => [ 306 | [CARD_SECTION_TYPE, 0] 307 | ] 308 | } 309 | 310 | renderer = Mobiledoc::HTMLRenderer.new(cards: [card]) 311 | 312 | expect{ renderer.render(mobiledoc) }.to_not raise_error 313 | end 314 | 315 | it 'rendering nested mobiledocs in cards' do 316 | card = Module.new do 317 | module_function 318 | 319 | def name 320 | 'nested-card' 321 | end 322 | 323 | def type 324 | 'html' 325 | end 326 | 327 | def render(env, payload, options) 328 | options[:renderer].render(payload['mobiledoc']) 329 | end 330 | end 331 | 332 | inner_mobiledoc = { 333 | 'version' => MOBILEDOC_VERSION, 334 | 'sections' => [ 335 | [MARKUP_SECTION_TYPE, 'P', [ 336 | [MARKUP_MARKER_TYPE, [], 0, 'hello world']] 337 | ] 338 | ] 339 | } 340 | 341 | mobiledoc = { 342 | 'version' => MOBILEDOC_VERSION, 343 | 'atoms' => [], 344 | 'cards' => [ 345 | [card.name, { 'mobiledoc' => inner_mobiledoc }] 346 | ], 347 | 'markups' => [], 348 | 'sections' => [ 349 | [CARD_SECTION_TYPE, 0] 350 | ] 351 | } 352 | 353 | renderer = Mobiledoc::HTMLRenderer.new(cards: [card], card_options: { renderer: self }) 354 | 355 | rendered = renderer.render(mobiledoc) 356 | 357 | expect(rendered).to eq('

hello world

') 358 | end 359 | 360 | it 'rendering unknown card without unknown_card_handler throws' do 361 | card_name = 'missing-card' 362 | 363 | mobiledoc = { 364 | 'version' => MOBILEDOC_VERSION, 365 | 'atoms' => [], 366 | 'cards' => [ 367 | [card_name] 368 | ], 369 | 'markups' => [], 370 | 'sections' => [ 371 | [CARD_SECTION_TYPE, 0] 372 | ] 373 | } 374 | 375 | renderer = Mobiledoc::HTMLRenderer.new(cards: []) 376 | 377 | expect{ renderer.render(mobiledoc) }.to raise_error(%Q[Card "missing-card" not found]) 378 | end 379 | 380 | it 'rendering unknown card uses unknown_card_handler' do 381 | card_name = 'missing-card' 382 | expected_payload = {} 383 | expected_options = {} 384 | 385 | unknown_card_handler = Module.new do 386 | module_function 387 | 388 | def type 389 | 'html' 390 | end 391 | 392 | def render(env, payload, options) 393 | end 394 | end 395 | 396 | mobiledoc = { 397 | 'version' => MOBILEDOC_VERSION, 398 | 'atoms' => [], 399 | 'cards' => [ 400 | [card_name, expected_payload] 401 | ], 402 | 'markups' => [], 403 | 'sections' => [ 404 | [CARD_SECTION_TYPE, 0] 405 | ] 406 | } 407 | 408 | expect(unknown_card_handler).to receive(:render).with({name: card_name}, expected_payload, expected_options) 409 | 410 | renderer = Mobiledoc::HTMLRenderer.new(cards: [], card_options: expected_options, unknown_card_handler: unknown_card_handler) 411 | rendered = renderer.render(mobiledoc) 412 | end 413 | 414 | it 'throws if given an object of cards' do 415 | expect{ Mobiledoc::HTMLRenderer.new(cards: {}) }.to raise_exception('`cards` must be passed as an array') 416 | end 417 | 418 | it 'XSS: tag contents are entity escaped' do 419 | xss = "" 420 | 421 | mobiledoc = { 422 | 'version' => MOBILEDOC_VERSION, 423 | 'atoms' => [], 424 | 'cards' => [], 425 | 'markups' => [], 426 | 'sections' => [ 427 | [MARKUP_SECTION_TYPE, 'P', [ 428 | [MARKUP_MARKER_TYPE, [], 0, xss]] 429 | ] 430 | ] 431 | } 432 | 433 | rendered = render(mobiledoc) 434 | 435 | expect(rendered).to eq("

<script>alert('xx')</script>

") 436 | end 437 | 438 | it 'multiple spaces should preserve whitespace with nbsps' do 439 | space = ' ' 440 | text = [ space * 4, 'some', space * 5, 'text', space * 6].join 441 | 442 | mobiledoc = { 443 | 'version' => MOBILEDOC_VERSION, 444 | 'atoms' => [], 445 | 'cards' => [], 446 | 'markups' => [], 447 | 'sections' => [ 448 | [MARKUP_SECTION_TYPE, 'P', [ 449 | [MARKUP_MARKER_TYPE, [], 0, text]] 450 | ] 451 | ] 452 | } 453 | 454 | rendered = render(mobiledoc) 455 | 456 | sn = '  ' 457 | expected_text = [ sn * 2, 'some', sn * 2, space, 'text', sn * 3 ].join 458 | 459 | expect(rendered).to eq("

#{expected_text}

") 460 | end 461 | 462 | it 'throws when given an unexpected mobiledoc version' do 463 | mobiledoc = { 464 | 'version' => '0.1.0', 465 | 'atoms' => [], 466 | 'cards' => [], 467 | 'markups' => [], 468 | 'sections' => [] 469 | } 470 | 471 | expect{ render(mobiledoc) }.to raise_error('Unexpected Mobiledoc version "0.1.0"') 472 | 473 | mobiledoc['version'] = '0.2.1' 474 | 475 | expect{ render(mobiledoc) }.to raise_error('Unexpected Mobiledoc version "0.2.1"') 476 | end 477 | 478 | it 'XSS: unexpected markup and list section tag names are not renderered' do 479 | mobiledoc = { 480 | 'version' => MOBILEDOC_VERSION, 481 | 'atoms' => [], 482 | 'cards' => [], 483 | 'markups' => [], 484 | 'sections' => [ 485 | [MARKUP_SECTION_TYPE, 'script', [ 486 | [MARKUP_MARKER_TYPE, [], 0, 'alert("markup section XSS")'] 487 | ]], 488 | [LIST_SECTION_TYPE, 'script', [ 489 | [[MARKUP_MARKER_TYPE, [], 0, 'alert("list section XSS")']] 490 | ]] 491 | ] 492 | } 493 | 494 | rendered = render(mobiledoc) 495 | 496 | expect(rendered).to_not match(/script/) 497 | end 498 | 499 | it 'XSS: unexpected markup types are not rendered' do 500 | mobiledoc = { 501 | 'version' => MOBILEDOC_VERSION, 502 | 'atoms' => [], 503 | 'cards' => [], 504 | 'markups' => [ 505 | ['b'], # valid 506 | ['em'], # valid 507 | ['script'] # invalid 508 | ], 509 | 'sections' => [ 510 | [MARKUP_SECTION_TYPE, 'p', [ 511 | [MARKUP_MARKER_TYPE, [0], 0, 'bold text'], 512 | [MARKUP_MARKER_TYPE, [1,2], 3, 'alert("markup XSS")'], 513 | [MARKUP_MARKER_TYPE, [], 0, 'plain text'] 514 | ]] 515 | ] 516 | } 517 | 518 | rendered = render(mobiledoc) 519 | 520 | expect(rendered).to_not match(/script/) 521 | end 522 | 523 | it 'renders a mobiledoc with atom' do 524 | atom_name = 'hello-atom' 525 | 526 | expected_options = { some: :options } 527 | expected_payload = { some: :payload } 528 | expected_value = 'Bob' 529 | 530 | atom = Module.new do 531 | module_function 532 | 533 | def name 534 | 'hello-atom' 535 | end 536 | 537 | def type 538 | 'html' 539 | end 540 | 541 | def render(env, value, payload, options) 542 | end 543 | end 544 | 545 | mobiledoc = { 546 | 'version' => MOBILEDOC_VERSION, 547 | 'atoms' => [ 548 | [atom_name, expected_value, expected_payload] 549 | ], 550 | 'cards' => [], 551 | 'markups' => [], 552 | 'sections' => [ 553 | [MARKUP_SECTION_TYPE, 'P', [ 554 | [ATOM_MARKER_TYPE, [], 0, 0]] 555 | ] 556 | ] 557 | } 558 | 559 | expect(atom).to receive(:render).with({name: atom_name}, expected_value, expected_payload, expected_options).and_return("Hello Bob") 560 | 561 | renderer = Mobiledoc::HTMLRenderer.new(atoms: [atom], card_options: expected_options) 562 | rendered = renderer.render(mobiledoc) 563 | 564 | expect(rendered).to eq('

Hello Bob

') 565 | end 566 | 567 | it 'throws when given atom with invalid type' do 568 | bad_atom = Module.new do 569 | module_function 570 | 571 | def name 572 | 'bad' 573 | end 574 | 575 | def type 576 | 'other' 577 | end 578 | 579 | def render(env, value, payload, options) 580 | end 581 | end 582 | 583 | expect{ Mobiledoc::HTMLRenderer.new(atoms: [bad_atom]) }.to raise_error(%Q[Atom "bad" must be of type "html", was "other"]) 584 | end 585 | 586 | it 'throws when given atom without `render`' do 587 | bad_atom = Module.new do 588 | module_function 589 | 590 | def name 591 | 'bad' 592 | end 593 | 594 | def type 595 | 'html' 596 | end 597 | end 598 | 599 | expect{ Mobiledoc::HTMLRenderer.new(atoms: [bad_atom]) }.to raise_error(%Q[Atom "bad" must define `render`]) 600 | end 601 | 602 | it 'throws if atom render returns invalid result' do 603 | bad_atom = Module.new do 604 | module_function 605 | 606 | def name 607 | 'bad' 608 | end 609 | 610 | def type 611 | 'html' 612 | end 613 | 614 | def render(env, value, payload, options) 615 | Object.new 616 | end 617 | end 618 | 619 | mobiledoc = { 620 | 'version' => MOBILEDOC_VERSION, 621 | 'atoms' => [ 622 | [bad_atom.name, 'Bob', { id: 42 }] 623 | ], 624 | 'cards' => [], 625 | 'markups' => [], 626 | 'sections' => [ 627 | [MARKUP_SECTION_TYPE, 'P', [ 628 | [ATOM_MARKER_TYPE, [], 0, 0]] 629 | ] 630 | ] 631 | } 632 | 633 | renderer = Mobiledoc::HTMLRenderer.new(atoms: [bad_atom]) 634 | 635 | expect{ renderer.render(mobiledoc) }.to raise_error(/Atom "bad" must render html/) 636 | end 637 | 638 | it 'atom may render nothing' do 639 | atom = Module.new do 640 | module_function 641 | 642 | def name 643 | 'ok' 644 | end 645 | 646 | def type 647 | 'html' 648 | end 649 | 650 | def render(env, value, payload, options) 651 | end 652 | end 653 | 654 | mobiledoc = { 655 | 'version' => MOBILEDOC_VERSION, 656 | 'atoms' => [ 657 | [atom.name, 'Bob', { id: 42 }] 658 | ], 659 | 'cards' => [], 660 | 'markups' => [], 661 | 'sections' => [ 662 | [MARKUP_SECTION_TYPE, 'P', [ 663 | [ATOM_MARKER_TYPE, [], 0, 0]] 664 | ] 665 | ] 666 | } 667 | 668 | renderer = Mobiledoc::HTMLRenderer.new(atoms: [atom]) 669 | 670 | expect{ renderer.render(mobiledoc) }.to_not raise_error 671 | end 672 | 673 | it 'throws when rendering unknown atom without unknown_atom_handler' do 674 | atom_name = 'missing-atom' 675 | 676 | mobiledoc = { 677 | 'version' => MOBILEDOC_VERSION, 678 | 'atoms' => [ 679 | [atom_name, 'Bob', { id: 42 }] 680 | ], 681 | 'cards' => [], 682 | 'markups' => [], 683 | 'sections' => [ 684 | [MARKUP_SECTION_TYPE, 'P', [ 685 | [ATOM_MARKER_TYPE, [], 0, 0]] 686 | ] 687 | ] 688 | } 689 | 690 | renderer = Mobiledoc::HTMLRenderer.new(atoms: []) 691 | 692 | expect{ renderer.render(mobiledoc) }.to raise_error(%Q[Atom "missing-atom" not found]) 693 | end 694 | 695 | it 'rendering unknown atom uses unknown_atom_handler' do 696 | atom_name = 'missing-atom' 697 | 698 | expected_value = 'Bob' 699 | expected_payload = { some: :payload } 700 | expected_options = { some: :options } 701 | 702 | unknown_atom_handler = Module.new do 703 | module_function 704 | 705 | def type 706 | 'html' 707 | end 708 | 709 | def render(env, value, payload, options) 710 | end 711 | end 712 | 713 | mobiledoc = { 714 | 'version' => MOBILEDOC_VERSION, 715 | 'atoms' => [ 716 | [atom_name, expected_value, expected_payload] 717 | ], 718 | 'cards' => [], 719 | 'markups' => [], 720 | 'sections' => [ 721 | [MARKUP_SECTION_TYPE, 'P', [ 722 | [ATOM_MARKER_TYPE, [], 0, 0]] 723 | ] 724 | ] 725 | } 726 | 727 | expect(unknown_atom_handler).to receive(:render).with({name: atom_name}, expected_value, expected_payload, expected_options) 728 | 729 | renderer = Mobiledoc::HTMLRenderer.new(atoms: [], card_options: expected_options, unknown_atom_handler: unknown_atom_handler) 730 | rendered = renderer.render(mobiledoc) 731 | end 732 | 733 | context '0.3.1' do 734 | it 'renders 0.3.1-specific sections and markups' do 735 | mobiledoc = { 736 | 'version' => '0.3.1', 737 | 'atoms' => [], 738 | 'cards' => [], 739 | 'markups' => [ 740 | ['CODE'] 741 | ], 742 | 'sections' => [ 743 | [MARKUP_SECTION_TYPE, 'ASIDE', [ 744 | [MARKUP_MARKER_TYPE, [0], 1, 'hello world']] 745 | ] 746 | ] 747 | } 748 | 749 | rendered = render(mobiledoc) 750 | 751 | expect(rendered).to eq('
') 752 | end 753 | end 754 | 755 | context '0.3.2' do 756 | it 'renders 0.3.2-specific sections with attributes' do 757 | mobiledoc = { 758 | 'version' => '0.3.2', 759 | 'atoms' => [], 760 | 'cards' => [], 761 | 'markups' => [ 762 | ['CODE'] 763 | ], 764 | 'sections' => [ 765 | [ 766 | MARKUP_SECTION_TYPE, 767 | 'ASIDE', 768 | [ 769 | [MARKUP_MARKER_TYPE, [0], 1, 'hello world'] 770 | ], 771 | ["data-md-text-align", "center"] 772 | ] 773 | ] 774 | } 775 | 776 | rendered = render(mobiledoc) 777 | 778 | expect(rendered).to eq('
') 779 | end 780 | end 781 | end 782 | end 783 | -------------------------------------------------------------------------------- /spec/mobiledoc_html_renderer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Mobiledoc::HTMLRenderer do 4 | it 'has a version number' do 5 | expect(Mobiledoc::HTMLRenderer::VERSION).not_to be nil 6 | end 7 | 8 | it 'can be instantiated' do 9 | expect { described_class.new }.to_not raise_exception 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'mobiledoc_html_renderer' 3 | --------------------------------------------------------------------------------