Repository: springload/draftjs_exporter

Issue:
Create a new dependency-free DOM backing engine
Make a dependency-free implementation of `DOMEngine` based on `xml.etree.ElementTree` or `xml.etree.cElementTree`. This might have a positive performance impact, as well as facilitating the use of the exporter.

For reference, here is my tentative implementation of an engine using `ElementTree`. For some reason it outputs wrappers twice, I would suspect a bug in `wrapper_state` that this particular implementation surfaces.

```python
class DOM_ETREE(DOMEngine):
    """
    lxml implementation of the DOM API.
    """
    @staticmethod
    def create_tag(type_, attr=None):
        if not attr:
            attr = {}

        return etree.Element(type_, attrib=attr)

    @staticmethod
    def parse_html(markup):
        pass

    @staticmethod
    def append_child(elt, child):
        if hasattr(child, 'tag'):
            elt.append(child)
        else:
            c = etree.Element('fragment')
            c.text = child
            elt.append(c)

    @staticmethod
    def render(elt):
        return re.sub(r'(</?fragment>)', '', etree.tostring(elt, method='html'))

    @staticmethod
    def render_debug(elt):
        return etree.tostring(elt, method='html')
```

Edit: once implemented, this could become the default engine so people can more easily "choose their own adventure" with any other engine, but still have a working default when doing `pip install draftjs_exporter`.

Discussion:
su27: Good idea, it's quite efficient and straightforward.

One thing, sometimes I need to insert an HTML string into the DOM structure, for example, in order to add a 3rd-party video into my article, I have to get the embedded HTML from the video website, parse it to DOM objects, and append them to the draftjs DOM structure. So, if the `parse_html` method is removed, I still need a way to insert raw HTML into the article.
thibaudcolas: @BertrandBordage good question about the static methods. I don't remember my reasoning then. It doesn't feel worth the refactoring because of how seldom-used this part of the exporter's API is, but in this case yep it does seem completely over-engineered.

@su27 I think it would be possible to add `parse_html` onto this, all that's needed would be to circumvent the escaping of the HTML string in the `render` method. The big caveat is that contrary to the other BeautifulSoup and lxml engines, the HTML would be treated as a dumb string without any processing at all (checking its validity, escaping the parts that need escaping, etc). Does that sound reasonable / useful?


su27: @thibaudcolas That sounds good to me. Although it may cause problems if the user doesn't process the HTML string correctly, but I think the validating responsibility should belong to the user, not the engine. If one decides to use the string-based engine, he/she should make sure the input is legal, including tag names, attribute names, and the "raw HTML".

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f842979..ce3b362 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,27 @@
 > All notable changes to this project will be documented in this file.
 This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## Unreleased
+
+### Added
+
+- Add new string-based dependency-free DOM backing engine, with much better performance, thanks to the expertise of @BertrandBordage (#77).
+
+### Changed
+
+- Pre-compile regexes in html5lib engine for performance improvements (#76).
+
+### How to upgrade
+
+There is no need to make any changes to keep using the previous engines (html5lib, lxml). To switch to the new string engine, opt-in via the config:
+
+```diff
+exporter = HTML({
++    # Specify which DOM backing engine to use.
++    'engine': 'string',
+})
+```
+
 ## [v1.0.0](https://github.com/springload/draftjs_exporter/releases/tag/v1.0.0)
 
 > This release is functionally identical to the previous one, `v0.9.0`.
diff --git a/README.rst b/README.rst
index 2e12755..dfaab02 100644
--- a/README.rst
+++ b/README.rst
@@ -237,10 +237,23 @@ This fallback component can now control the exporter behavior when normal compon
 
 See ``examples.py`` in the repository for more details.
 
-lxml backing engine
-~~~~~~~~~~~~~~~~~~~
+Alternative backing engines
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+By default the exporter uses ``html5lib`` via BeautifulSoup to build the DOM tree. There are two alternative backing engines: ``string`` and ``lxml``.
+
+The ``string`` engine is the fastest, and does not have any dependencies. Its only drawback is that the ``parse_html`` method does not escape/sanitise HTML like that of other engines.
+
+To use it, add the following to the exporter config:
+
+.. code:: python
+
+    config = {
+        # Specify which DOM backing engine to use.
+        'engine': 'string',
+    }
 
-By default the exporter uses ``html5lib`` via BeautifulSoup to build DOM tree. ``lxml`` is also supported. lxml is more performant, but it requires ``libxml2`` and ``libxslt`` to be available on your system.
+``lxml`` is also supported. It requires ``libxml2`` and ``libxslt`` to be available on your system.
 
 .. code:: sh
 
diff --git a/docs/README.md b/docs/README.md
index 46706c5..e4bc087 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -23,9 +23,7 @@ draftjs_exporter documentation
 - `style` prop is rendered as-is if it is a string, or can also be a dict in which case its properties are converted into a string using `camel_to_dash`.
 - Invalid attributes are left for the BeautifulSoup / html5lib parser to handle.
 - HTML escaping is automatically done by BeautifulSoup / html5lib.
-
-### Unsupported markup
-
+- The string engine escapes single quotes.
 
 ## R&D notes
 
diff --git a/draftjs_exporter/dom.py b/draftjs_exporter/dom.py
index b2d31d2..406667b 100644
--- a/draftjs_exporter/dom.py
+++ b/draftjs_exporter/dom.py
@@ -26,6 +26,7 @@ class DOM(object):
 
     HTML5LIB = 'html5lib'
     LXML = 'lxml'
+    STRING = 'string'
 
     dom = DOM_HTML5LIB
 
@@ -48,6 +49,9 @@ class DOM(object):
             elif engine.lower() == cls.LXML:
                 from draftjs_exporter.engines.lxml import DOM_LXML
                 cls.dom = DOM_LXML
+            elif engine.lower() == cls.STRING:
+                from draftjs_exporter.engines.string import DOMString
+                cls.dom = DOMString
             else:
                 raise ConfigException('Invalid DOM engine.')
 
diff --git a/draftjs_exporter/engines/string.py b/draftjs_exporter/engines/string.py
new file mode 100644
index 0000000..4cc4c04
--- /dev/null
+++ b/draftjs_exporter/engines/string.py
@@ -0,0 +1,107 @@
+from __future__ import absolute_import, unicode_literals
+
+from draftjs_exporter.engines.base import DOMEngine
+
+try:
+    # Python 3.2 and above.
+    from html import escape
+except ImportError:
+    import cgi
+
+    def escape(s):
+        # Force quote escaping in Python 2.7.
+        return cgi.escape(s, quote=True).replace('\'', '&#x27;')
+
+# http://w3c.github.io/html/single-page.html#void-elements
+# https://github.com/html5lib/html5lib-python/blob/0cae52b2073e3f2220db93a7650901f2200f2a13/html5lib/constants.py#L560
+VOID_ELEMENTS = {
+    'area',
+    'base',
+    'br',
+    'col',
+    'embed',
+    'hr',
+    'img',
+    'input',
+    'link',
+    'meta',
+    'param',
+    'source',
+    'track',
+    'wbr',
+}
+
+
+class DOMString(DOMEngine):
+    """
+    String concatenation implementation of the DOM API.
+    """
+
+    @staticmethod
+    def create_tag(type_, attr=None):
+        return {
+            'type': type_,
+            'attr': attr,
+            'children': [],
+        }
+
+    @staticmethod
+    def parse_html(markup):
+        """
+        Allows inserting arbitrary HTML into the exporter output.
+        Treats the HTML as if it had been escaped and was safe already.
+        """
+        return {
+            'type': 'escaped_html',
+            'attr': None,
+            'children': None,
+            'markup': markup,
+        }
+
+    @staticmethod
+    def append_child(elt, child):
+        # This check is necessary because the current wrapper_state implementation
+        # has an issue where it inserts elements multiple times.
+        # This must be skipped for text, which can be duplicated.
+        is_existing_ref = isinstance(child, dict) and child in elt['children']
+        if not is_existing_ref:
+            elt['children'].append(child)
+
+    @staticmethod
+    def render_attrs(attr):
+        return ''.join(sorted([' %s="%s"' % (k, escape(v)) for k, v in attr.items()]))
+
+    @staticmethod
+    def render_children(children):
+        return ''.join([DOMString.render(c) if isinstance(c, dict) else escape(c) for c in children])
+
+    @staticmethod
+    def render(elt):
+        type_ = elt['type']
+        attr = DOMString.render_attrs(elt['attr']) if elt['attr'] else ''
+        children = DOMString.render_children(elt['children']) if elt['children'] else ''
+
+        if type_ == 'fragment':
+            return children
+
+        if type_ in VOID_ELEMENTS:
+            return '<%s%s/>' % (type_, attr)
+
+        if type_ == 'escaped_html':
+            return elt['markup']
+
+        return '<%s%s>%s</%s>' % (type_, attr, children, type_)
+
+    @staticmethod
+    def render_debug(elt):
+        type_ = elt['type']
+        attr = DOMString.render_attrs(elt['attr']) if elt['attr'] else ''
+        children = DOMString.render_children(elt['children']) if elt['children'] else ''
+
+        if type_ in VOID_ELEMENTS:
+            return '<%s%s/>' % (type_, attr)
+
+        if type_ == 'escaped_html':
+            return elt['markup']
+
+        return '<%s%s>%s</%s>' % (type_, attr, children, type_)
diff --git a/example.py b/example.py
index 7512d5b..ca74ad1 100644
--- a/example.py
+++ b/example.py
@@ -194,7 +194,7 @@ config = {
         Linkify,
     ],
     # Specify which DOM backing engine to use.
-    'engine': 'html5lib',
+    'engine': 'string',
 }
 
 exporter = HTML(config)


Test patch:
diff --git a/tests/engines/test_engines_differences.py b/tests/engines/test_engines_differences.py
index a73e551..3d616a3 100644
--- a/tests/engines/test_engines_differences.py
+++ b/tests/engines/test_engines_differences.py
@@ -5,6 +5,7 @@ import unittest
 
 from draftjs_exporter.engines.html5lib import DOM_HTML5LIB
 from draftjs_exporter.engines.lxml import DOM_LXML
+from draftjs_exporter.engines.string import DOMString
 
 
 class TestDOMEnginesDifferences(unittest.TestCase):
@@ -14,6 +15,9 @@ class TestDOMEnginesDifferences(unittest.TestCase):
     def test_lxml_self_closing_tags(self):
         self.assertEqual(DOM_LXML.render_debug(DOM_LXML.create_tag('hr')), '<hr>')
 
+    def test_string_self_closing_tags(self):
+        self.assertEqual(DOMString.render_debug(DOMString.create_tag('hr')), '<hr/>')
+
     def test_html5lib_invalid_attributes(self):
         self.assertEqual(DOM_HTML5LIB.render_debug(DOM_HTML5LIB.create_tag('div', {'*ngFor': 'test'})), '<div *ngFor="test"></div>')
 
@@ -21,6 +25,9 @@ class TestDOMEnginesDifferences(unittest.TestCase):
         with self.assertRaises(ValueError):
             DOM_LXML.render_debug(DOM_LXML.create_tag('div', {'*ngFor': 'test'}))
 
+    def test_string_invalid_attributes(self):
+        self.assertEqual(DOMString.render_debug(DOMString.create_tag('div', {'*ngFor': 'test'})), '<div *ngFor="test"></div>')
+
     def test_html5lib_namespaced_attributes(self):
         bs_elt = DOM_HTML5LIB.create_tag('svg')
         DOM_HTML5LIB.append_child(bs_elt, DOM_HTML5LIB.create_tag('use', {'xlink:href': 'test'}))
@@ -30,3 +37,32 @@ class TestDOMEnginesDifferences(unittest.TestCase):
         lxml_elt = DOM_LXML.create_tag('svg')
         DOM_LXML.append_child(lxml_elt, DOM_LXML.create_tag('use', {'xlink:href': 'test'}))
         self.assertEqual(DOM_LXML.render_debug(lxml_elt), '<svg><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="test"></use></svg>')
+
+    def test_string_namespaced_attributes(self):
+        bs_elt = DOMString.create_tag('svg')
+        DOMString.append_child(bs_elt, DOMString.create_tag('use', {'xlink:href': 'test'}))
+        self.assertEqual(DOMString.render_debug(bs_elt), '<svg><use xlink:href="test"></use></svg>')
+
+    def test_html5lib_html_escaping(self):
+        self.assertEqual(DOM_HTML5LIB.render_debug(DOM_HTML5LIB.create_tag('img', {
+            'alt': '< " \' < > &',
+        })), '<img alt="&lt; &quot; \' &lt; &gt; &amp;"/>')
+
+    def test_lxml_html_escaping(self):
+        self.assertEqual(DOM_LXML.render_debug(DOM_LXML.create_tag('img', {
+            'alt': '< " \' < > &',
+        })), '<img alt="&lt; &quot; \' &lt; &gt; &amp;">')
+
+    def test_string_html_escaping(self):
+        self.assertEqual(DOMString.render_debug(DOMString.create_tag('img', {
+            'alt': '< " \' < > &',
+        })), '<img alt="&lt; &quot; &#x27; &lt; &gt; &amp;"/>')
+
+    def test_html5lib_html_parsing(self):
+        self.assertEqual(DOM_HTML5LIB.render_debug(DOM_HTML5LIB.parse_html('<p>Invalid < " > &</p>')), '<p>Invalid &lt; " &gt; &amp;</p>')
+
+    def test_lxml_html_parsing(self):
+        self.assertEqual(DOM_LXML.render_debug(DOM_LXML.parse_html('<p>Invalid < " > &</p>')), '<p>Invalid &lt; " &gt; &amp;</p>')
+
+    def test_string_html_parsing(self):
+        self.assertEqual(DOMString.render_debug(DOMString.parse_html('<p>Invalid < " > &</p>')), '<p>Invalid < " > &</p>')
diff --git a/tests/engines/test_engines_string.py b/tests/engines/test_engines_string.py
new file mode 100644
index 0000000..765ff3f
--- /dev/null
+++ b/tests/engines/test_engines_string.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, unicode_literals
+
+import unittest
+
+from draftjs_exporter.engines.string import DOMString
+
+
+class TestDOMString(unittest.TestCase):
+    def test_create_tag(self):
+        self.assertEqual(DOMString.render_debug(DOMString.create_tag('p', {'class': 'intro'})), '<p class="intro"></p>')
+
+    def test_create_tag_empty(self):
+        self.assertEqual(DOMString.render_debug(DOMString.create_tag('p')), '<p></p>')
+
+    def test_parse_html(self):
+        self.assertEqual(DOMString.render(DOMString.parse_html('<p><span>Test text</span></p>')), '<p><span>Test text</span></p>')
+
+    def test_append_child(self):
+        parent = DOMString.create_tag('p')
+        DOMString.append_child(parent, DOMString.create_tag('span', {}))
+        self.assertEqual(DOMString.render_debug(parent), '<p><span></span></p>')
+
+    def test_render_attrs(self):
+        self.assertEqual(DOMString.render_attrs({
+            'src': 'src.png',
+            'alt': 'img\'s alt',
+            'class': 'intro',
+        }), ' alt="img&#x27;s alt" class="intro" src="src.png"')
+
+
+    def test_render_children(self):
+        self.assertEqual(DOMString.render_children([
+            'render children',
+            DOMString.create_tag('p', {'class': 'intro'}),
+            'test test',
+        ]), 'render children<p class="intro"></p>test test')
+
+    def test_render(self):
+        self.assertEqual(DOMString.render_debug(DOMString.create_tag('p', {'class': 'intro'})), '<p class="intro"></p>')
+
+    def test_render_debug(self):
+        self.assertEqual(DOMString.render_debug(DOMString.create_tag('p', {'class': 'intro'})), '<p class="intro"></p>')
diff --git a/tests/test_exports.json b/tests/test_exports.json
index 5ec9d35..b4a7617 100644
--- a/tests/test_exports.json
+++ b/tests/test_exports.json
@@ -3,7 +3,8 @@
         "label": "Plain text",
         "output": {
             "html5lib": "<p>a</p>",
-            "lxml": "<p>a</p>"
+            "lxml": "<p>a</p>",
+            "string": "<p>a</p>"
         },
         "content_state": {
             "entityMap": {},
@@ -24,7 +25,8 @@
         "label": "Single inline style",
         "output": {
             "html5lib": "<p>asd<strong>f</strong></p>",
-            "lxml": "<p>asd<strong>f</strong></p>"
+            "lxml": "<p>asd<strong>f</strong></p>",
+            "string": "<p>asd<strong>f</strong></p>"
         },
         "content_state": {
             "entityMap": {},
@@ -51,7 +53,8 @@
         "label": "Nested inline styles",
         "output": {
             "html5lib": "<p><strong><em>BoldItalic</em></strong></p>",
-            "lxml": "<p><strong><em>BoldItalic</em></strong></p>"
+            "lxml": "<p><strong><em>BoldItalic</em></strong></p>",
+            "string": "<p><strong><em>BoldItalic</em></strong></p>"
         },
         "content_state": {
             "entityMap": {},
@@ -83,7 +86,8 @@
         "label": "Nested inline styles (inverted)",
         "output": {
             "html5lib": "<p><strong><em>ItalicBold</em></strong></p>",
-            "lxml": "<p><strong><em>ItalicBold</em></strong></p>"
+            "lxml": "<p><strong><em>ItalicBold</em></strong></p>",
+            "string": "<p><strong><em>ItalicBold</em></strong></p>"
         },
         "content_state": {
             "entityMap": {},
@@ -115,7 +119,8 @@
         "label": "Adjacent inline styles",
         "output": {
             "html5lib": "<p><em>Bold</em><strong>Italic</strong></p>",
-            "lxml": "<p><em>Bold</em><strong>Italic</strong></p>"
+            "lxml": "<p><em>Bold</em><strong>Italic</strong></p>",
+            "string": "<p><em>Bold</em><strong>Italic</strong></p>"
         },
         "content_state": {
             "entityMap": {},
@@ -147,7 +152,8 @@
         "label": "Style map defaults",
         "output": {
             "html5lib": "<p><strong>0</strong><code>1</code><em>2</em><u>3</u>
4
<sup>5</sup><sub>6</sub><mark>7</mark><q>8</q><small>9</small><samp>a</samp><ins>b</ins><del>c</del><kbd>d</kbd>ef</p>",
-            "lxml": "<p><strong>0</strong><code>1</code><em>2</em><u>3</u>
4
<sup>5</sup><sub>6</sub><mark>7</mark><q>8</q><small>9</small><samp>a</samp><ins>b</ins><del>c</del><kbd>d</kbd>ef</p>"
+            "lxml": "<p><strong>0</strong><code>1</code><em>2</em><u>3</u>
4
<sup>5</sup><sub>6</sub><mark>7</mark><q>8</q><small>9</small><samp>a</samp><ins>b</ins><del>c</del><kbd>d</kbd>ef</p>",
+            "string": "<p><strong>0</strong><code>1</code><em>2</em><u>3</u>
4
<sup>5</sup><sub>6</sub><mark>7</mark><q>8</q><small>9</small><samp>a</samp><ins>b</ins><del>c</del><kbd>d</kbd>ef</p>"
         },
         "content_state": {
             "entityMap": {},
@@ -239,7 +245,8 @@
         "label": "Entity",
         "output": {
             "html5lib": "<p><a href=\"/\" title=\"hi\"><em>a</em></a></p>",
-            "lxml": "<p><a href=\"/\" title=\"hi\"><em>a</em></a></p>"
+            "lxml": "<p><a href=\"/\" title=\"hi\"><em>a</em></a></p>",
+            "string": "<p><a href=\"/\" title=\"hi\"><em>a</em></a></p>"
         },
         "content_state": {
             "entityMap": {
@@ -282,7 +289,8 @@
         "label": "Entity with data-*",
         "output": {
             "html5lib": "<p><a data-=\"no\" data-False=\"bad\" data-id=\"42\" data-mutability=\"mutable\" extra=\"foo\" href=\"/\" title=\"hi\"><em>a</em></a></p>",
-            "lxml": "<p><a data-=\"no\" data-False=\"bad\" data-id=\"42\" data-mutability=\"mutable\" extra=\"foo\" href=\"/\" title=\"hi\"><em>a</em></a></p>"
+            "lxml": "<p><a data-=\"no\" data-False=\"bad\" data-id=\"42\" data-mutability=\"mutable\" extra=\"foo\" href=\"/\" title=\"hi\"><em>a</em></a></p>",
+            "string": "<p><a data-=\"no\" data-False=\"bad\" data-id=\"42\" data-mutability=\"mutable\" extra=\"foo\" href=\"/\" title=\"hi\"><em>a</em></a></p>"
         },
         "content_state": {
             "entityMap": {
@@ -329,7 +337,8 @@
         "label": "Entity with inline style",
         "output": {
             "html5lib": "<p><a href=\"/\"><em>a</em></a></p>",
-            "lxml": "<p><a href=\"/\"><em>a</em></a></p>"
+            "lxml": "<p><a href=\"/\"><em>a</em></a></p>",
+            "string": "<p><a href=\"/\"><em>a</em></a></p>"
         },
         "content_state": {
             "entityMap": {
@@ -370,7 +379,8 @@
         "label": "Ordered list",
         "output": {
             "html5lib": "<p>An ordered list:</p><ol><li>One</li><li>Two</li></ol>",
-            "lxml": "<p>An ordered list:</p><ol><li>One</li><li>Two</li></ol>"
+            "lxml": "<p>An ordered list:</p><ol><li>One</li><li>Two</li></ol>",
+            "string": "<p>An ordered list:</p><ol><li>One</li><li>Two</li></ol>"
         },
         "content_state": {
             "entityMap": {},
@@ -407,7 +417,8 @@
         "label": "All plain HTML elements we need",
         "output": {
             "html5lib": "<h2>Title 2</h2><h3>Title 3</h3><h4>Title 4</h4><h5>Title 5</h5><blockquote>Blockquote</blockquote><ul class=\"bullet-list\"><li>List item<ul class=\"bullet-list\"><li>Nested list item</li></ul></li></ul><ol><li>Ordered item <strong>(bold)</strong><ol><li>Nested ordered item <em>(italic)</em></li></ol></li></ol>",
-            "lxml": "<h2>Title 2</h2><h3>Title 3</h3><h4>Title 4</h4><h5>Title 5</h5><blockquote>Blockquote</blockquote><ul class=\"bullet-list\"><li>List item<ul class=\"bullet-list\"><li>Nested list item</li></ul></li></ul><ol><li>Ordered item <strong>(bold)</strong><ol><li>Nested ordered item <em>(italic)</em></li></ol></li></ol>"
+            "lxml": "<h2>Title 2</h2><h3>Title 3</h3><h4>Title 4</h4><h5>Title 5</h5><blockquote>Blockquote</blockquote><ul class=\"bullet-list\"><li>List item<ul class=\"bullet-list\"><li>Nested list item</li></ul></li></ul><ol><li>Ordered item <strong>(bold)</strong><ol><li>Nested ordered item <em>(italic)</em></li></ol></li></ol>",
+            "string": "<h2>Title 2</h2><h3>Title 3</h3><h4>Title 4</h4><h5>Title 5</h5><blockquote>Blockquote</blockquote><ul class=\"bullet-list\"><li>List item<ul class=\"bullet-list\"><li>Nested list item</li></ul></li></ul><ol><li>Ordered item <strong>(bold)</strong><ol><li>Nested ordered item <em>(italic)</em></li></ol></li></ol>"
         },
         "content_state": {
             "entityMap": {},
@@ -429,7 +440,8 @@
         "label": "From https://github.com/icelab/draft-js-ast-exporter/blob/651c807bea12d97dad6f4965ab40481c8f2130dd/test/fixtures/content.js",
         "output": {
             "html5lib": "<h2>DraftJS AST Exporter</h2><p>In your draft-js, <strong>exporting</strong> your <em>content</em>:</p><ol><li>From draft-js internals</li><li>To an abstract syntax tree</li><li>Extensibility.</li></ol><img src=\"http://placekitten.com/500/300\"/>:)<p>Find the project on <a href=\"https://github.com/icelab/draft-js-ast-exporter\">Github</a>.</p>",
-            "lxml": "<h2>DraftJS AST Exporter</h2><p>In your draft-js, <strong>exporting</strong> your <em>content</em>:</p><ol><li>From draft-js internals</li><li>To an abstract syntax tree</li><li>Extensibility.</li></ol><img src=\"http://placekitten.com/500/300\">:)<p>Find the project on <a href=\"https://github.com/icelab/draft-js-ast-exporter\">Github</a>.</p>"
+            "lxml": "<h2>DraftJS AST Exporter</h2><p>In your draft-js, <strong>exporting</strong> your <em>content</em>:</p><ol><li>From draft-js internals</li><li>To an abstract syntax tree</li><li>Extensibility.</li></ol><img src=\"http://placekitten.com/500/300\">:)<p>Find the project on <a href=\"https://github.com/icelab/draft-js-ast-exporter\">Github</a>.</p>",
+            "string": "<h2>DraftJS AST Exporter</h2><p>In your draft-js, <strong>exporting</strong> your <em>content</em>:</p><ol><li>From draft-js internals</li><li>To an abstract syntax tree</li><li>Extensibility.</li></ol><img src=\"http://placekitten.com/500/300\"/>:)<p>Find the project on <a href=\"https://github.com/icelab/draft-js-ast-exporter\">Github</a>.</p>"
         },
         "content_state": {
             "entityMap": {
@@ -539,7 +551,8 @@
         "label": "HTML entities escaping",
         "output": {
             "html5lib": "<p><a href=\"http://www.example.com/?a=1&amp;b=2\">http://www.example.com/?a=1&amp;b=2</a></p>",
-            "lxml": "<p><a href=\"http://www.example.com/?a=1&amp;b=2\">http://www.example.com/?a=1&amp;b=2</a></p>"
+            "lxml": "<p><a href=\"http://www.example.com/?a=1&amp;b=2\">http://www.example.com/?a=1&amp;b=2</a></p>",
+            "string": "<p><a href=\"http://www.example.com/?a=1&amp;b=2\">http://www.example.com/?a=1&amp;b=2</a></p>"
         },
         "content_state": {
             "entityMap": {
@@ -574,7 +587,8 @@
         "label": "Multiple decorators",
         "output": {
             "html5lib": "<p>search <a href=\"http://www.google.com#world\">http://www.google.com#world</a> for the <span class=\"hashtag\">#world</span></p>",
-            "lxml": "<p>search <a href=\"http://www.google.com#world\">http://www.google.com#world</a> for the <span class=\"hashtag\">#world</span></p>"
+            "lxml": "<p>search <a href=\"http://www.google.com#world\">http://www.google.com#world</a> for the <span class=\"hashtag\">#world</span></p>",
+            "string": "<p>search <a href=\"http://www.google.com#world\">http://www.google.com#world</a> for the <span class=\"hashtag\">#world</span></p>"
         },
         "content_state": {
             "entityMap": {},
@@ -595,7 +609,8 @@
         "label": "Big content export",
         "output": {
             "html5lib": "<h2>draftjs_exporter is an HTML exporter for <a href=\"https://github.com/facebook/draft-js\">Draft.js</a> content</h2><blockquote>Try it out by running this file!</blockquote><h3>Features 📝🍸</h3><p>The exporter aims to provide sensible defaults from basic block types and inline styles to HTML, that can easily be customised when required. For more advanced scenarios, an API is provided (mimicking React's <a href=\"https://facebook.github.io/react/docs/top-level-api.html#react.createelement\"><code>createElement</code></a>) to create custom rendering components of arbitrary complexity.</p><hr/><p>Here are some features worth highlighting:</p><ul class=\"bullet-list\"><li>Convert line breaks to <code>&lt;br&gt;</code><br/>elements.</li><li>Automatic conversion of entity data to HTML attributes (int &amp; boolean to string, <a href=\"https://facebook.github.io/react/docs/jsx-in-depth.html\"><code>style object</code> to <code>style string</code></a>).</li><li>Wrapped blocks (<code>&lt;li&gt; </code>elements go inside <code>&lt;ul&gt;</code> or <code>&lt;ol&gt;</code>).<ul class=\"bullet-list\"><li>With arbitrary nesting.<ul class=\"bullet-list\"><li>Common text styles: <strong>Bold</strong>, <em>Italic</em>, <u>Underline</u>, <code>Monospace</code>, 
Strikethrough.
 <kbd>cmd + b</kbd></li><li>
Overlapping 
<strong>
te
</strong><strong><em>xt</em></strong><em> styles. </em><strong style=\"text-decoration: underline;\">Custom styles</strong> too!<ul class=\"bullet-list\"><li><span class=\"hashtag\">#hashtag</span> support via <a href=\"https://github.com/springload/draftjs_exporter/pull/17\">#CompositeDecorators</a>.<ul class=\"bullet-list\"><li>Linkify URLs too! <a href=\"http://example.com/\">http://example.com/</a></li></ul></li></ul></li><li>Depth can go back and forth, it works fiiine (1)</li></ul></li><li>Depth can go back and forth, it works fiiine (2)<ul class=\"bullet-list\"><li>Depth can go back and forth, it works fiiine (3)</li></ul></li><li>Depth can go back and forth, it works fiiine (4)</li></ul></li><li>Depth can go back and forth, it works fiiine (5)</li></ul><img alt=\"Test image alt text\" height=\"200\" src=\"https://placekitten.com/g/300/200\" width=\"300\"/><h3>For developers \ud83d\ude80</h3><ol><li>Import the library</li><li>Define your configuration</li><li>Go!<ol><li>Optionally, define your custom components.</li></ol></li></ol><pre><code>def Blockquote(props):\n    block_data = props['block']['data']\n    return DOM.create_element('blockquote', {\n        'cite': block_data.get('cite')\n    }, props['children'])\n</code></pre><p>Voilà!</p>",
-            "lxml": "<h2>draftjs_exporter is an HTML exporter for <a href=\"https://github.com/facebook/draft-js\">Draft.js</a> content</h2><blockquote>Try it out by running this file!</blockquote><h3>Features 📝🍸</h3><p>The exporter aims to provide sensible defaults from basic block types and inline styles to HTML, that can easily be customised when required. For more advanced scenarios, an API is provided (mimicking React's <a href=\"https://facebook.github.io/react/docs/top-level-api.html#react.createelement\"><code>createElement</code></a>) to create custom rendering components of arbitrary complexity.</p><hr><p>Here are some features worth highlighting:</p><ul class=\"bullet-list\"><li>Convert line breaks to <code>&lt;br&gt;</code><br>elements.</li><li>Automatic conversion of entity data to HTML attributes (int &amp; boolean to string, <a href=\"https://facebook.github.io/react/docs/jsx-in-depth.html\"><code>style object</code> to <code>style string</code></a>).</li><li>Wrapped blocks (<code>&lt;li&gt; </code>elements go inside <code>&lt;ul&gt;</code> or <code>&lt;ol&gt;</code>).<ul class=\"bullet-list\"><li>With arbitrary nesting.<ul class=\"bullet-list\"><li>Common text styles: <strong>Bold</strong>, <em>Italic</em>, <u>Underline</u>, <code>Monospace</code>, 
Strikethrough.
 <kbd>cmd + b</kbd></li><li>
Overlapping 
<strong>
te
</strong><strong><em>xt</em></strong><em> styles. </em><strong style=\"text-decoration: underline;\">Custom styles</strong> too!<ul class=\"bullet-list\"><li><span class=\"hashtag\">#hashtag</span> support via <a href=\"https://github.com/springload/draftjs_exporter/pull/17\">#CompositeDecorators</a>.<ul class=\"bullet-list\"><li>Linkify URLs too! <a href=\"http://example.com/\">http://example.com/</a></li></ul></li></ul></li><li>Depth can go back and forth, it works fiiine (1)</li></ul></li><li>Depth can go back and forth, it works fiiine (2)<ul class=\"bullet-list\"><li>Depth can go back and forth, it works fiiine (3)</li></ul></li><li>Depth can go back and forth, it works fiiine (4)</li></ul></li><li>Depth can go back and forth, it works fiiine (5)</li></ul><img alt=\"Test image alt text\" height=\"200\" src=\"https://placekitten.com/g/300/200\" width=\"300\"><h3>For developers \ud83d\ude80</h3><ol><li>Import the library</li><li>Define your configuration</li><li>Go!<ol><li>Optionally, define your custom components.</li></ol></li></ol><pre><code>def Blockquote(props):\n    block_data = props['block']['data']\n    return DOM.create_element('blockquote', {\n        'cite': block_data.get('cite')\n    }, props['children'])\n</code></pre><p>Voilà!</p>"
+            "lxml": "<h2>draftjs_exporter is an HTML exporter for <a href=\"https://github.com/facebook/draft-js\">Draft.js</a> content</h2><blockquote>Try it out by running this file!</blockquote><h3>Features 📝🍸</h3><p>The exporter aims to provide sensible defaults from basic block types and inline styles to HTML, that can easily be customised when required. For more advanced scenarios, an API is provided (mimicking React's <a href=\"https://facebook.github.io/react/docs/top-level-api.html#react.createelement\"><code>createElement</code></a>) to create custom rendering components of arbitrary complexity.</p><hr><p>Here are some features worth highlighting:</p><ul class=\"bullet-list\"><li>Convert line breaks to <code>&lt;br&gt;</code><br>elements.</li><li>Automatic conversion of entity data to HTML attributes (int &amp; boolean to string, <a href=\"https://facebook.github.io/react/docs/jsx-in-depth.html\"><code>style object</code> to <code>style string</code></a>).</li><li>Wrapped blocks (<code>&lt;li&gt; </code>elements go inside <code>&lt;ul&gt;</code> or <code>&lt;ol&gt;</code>).<ul class=\"bullet-list\"><li>With arbitrary nesting.<ul class=\"bullet-list\"><li>Common text styles: <strong>Bold</strong>, <em>Italic</em>, <u>Underline</u>, <code>Monospace</code>, 
Strikethrough.
 <kbd>cmd + b</kbd></li><li>
Overlapping 
<strong>
te
</strong><strong><em>xt</em></strong><em> styles. </em><strong style=\"text-decoration: underline;\">Custom styles</strong> too!<ul class=\"bullet-list\"><li><span class=\"hashtag\">#hashtag</span> support via <a href=\"https://github.com/springload/draftjs_exporter/pull/17\">#CompositeDecorators</a>.<ul class=\"bullet-list\"><li>Linkify URLs too! <a href=\"http://example.com/\">http://example.com/</a></li></ul></li></ul></li><li>Depth can go back and forth, it works fiiine (1)</li></ul></li><li>Depth can go back and forth, it works fiiine (2)<ul class=\"bullet-list\"><li>Depth can go back and forth, it works fiiine (3)</li></ul></li><li>Depth can go back and forth, it works fiiine (4)</li></ul></li><li>Depth can go back and forth, it works fiiine (5)</li></ul><img alt=\"Test image alt text\" height=\"200\" src=\"https://placekitten.com/g/300/200\" width=\"300\"><h3>For developers \ud83d\ude80</h3><ol><li>Import the library</li><li>Define your configuration</li><li>Go!<ol><li>Optionally, define your custom components.</li></ol></li></ol><pre><code>def Blockquote(props):\n    block_data = props['block']['data']\n    return DOM.create_element('blockquote', {\n        'cite': block_data.get('cite')\n    }, props['children'])\n</code></pre><p>Voilà!</p>",
+            "string": "<h2>draftjs_exporter is an HTML exporter for <a href=\"https://github.com/facebook/draft-js\">Draft.js</a> content</h2><blockquote>Try it out by running this file!</blockquote><h3>Features 📝🍸</h3><p>The exporter aims to provide sensible defaults from basic block types and inline styles to HTML, that can easily be customised when required. For more advanced scenarios, an API is provided (mimicking React&#x27;s <a href=\"https://facebook.github.io/react/docs/top-level-api.html#react.createelement\"><code>createElement</code></a>) to create custom rendering components of arbitrary complexity.</p><hr/><p>Here are some features worth highlighting:</p><ul class=\"bullet-list\"><li>Convert line breaks to <code>&lt;br&gt;</code><br/>elements.</li><li>Automatic conversion of entity data to HTML attributes (int &amp; boolean to string, <a href=\"https://facebook.github.io/react/docs/jsx-in-depth.html\"><code>style object</code> to <code>style string</code></a>).</li><li>Wrapped blocks (<code>&lt;li&gt; </code>elements go inside <code>&lt;ul&gt;</code> or <code>&lt;ol&gt;</code>).<ul class=\"bullet-list\"><li>With arbitrary nesting.<ul class=\"bullet-list\"><li>Common text styles: <strong>Bold</strong>, <em>Italic</em>, <u>Underline</u>, <code>Monospace</code>, 
Strikethrough.
 <kbd>cmd + b</kbd></li><li>
Overlapping 
<strong>
te
</strong><strong><em>xt</em></strong><em> styles. </em><strong style=\"text-decoration: underline;\">Custom styles</strong> too!<ul class=\"bullet-list\"><li><span class=\"hashtag\">#hashtag</span> support via <a href=\"https://github.com/springload/draftjs_exporter/pull/17\">#CompositeDecorators</a>.<ul class=\"bullet-list\"><li>Linkify URLs too! <a href=\"http://example.com/\">http://example.com/</a></li></ul></li></ul></li><li>Depth can go back and forth, it works fiiine (1)</li></ul></li><li>Depth can go back and forth, it works fiiine (2)<ul class=\"bullet-list\"><li>Depth can go back and forth, it works fiiine (3)</li></ul></li><li>Depth can go back and forth, it works fiiine (4)</li></ul></li><li>Depth can go back and forth, it works fiiine (5)</li></ul><img alt=\"Test image alt text\" height=\"200\" src=\"https://placekitten.com/g/300/200\" width=\"300\"/><h3>For developers \ud83d\ude80</h3><ol><li>Import the library</li><li>Define your configuration</li><li>Go!<ol><li>Optionally, define your custom components.</li></ol></li></ol><pre><code>def Blockquote(props):\n    block_data = props[&#x27;block&#x27;][&#x27;data&#x27;]\n    return DOM.create_element(&#x27;blockquote&#x27;, {\n        &#x27;cite&#x27;: block_data.get(&#x27;cite&#x27;)\n    }, props[&#x27;children&#x27;])\n</code></pre><p>Voilà!</p>"
         },
         "content_state": {
             "entityMap": {
diff --git a/tests/test_exports.py b/tests/test_exports.py
index b9522be..f9b8650 100644
--- a/tests/test_exports.py
+++ b/tests/test_exports.py
@@ -18,11 +18,6 @@ from tests.test_entities import HR, Image, Link
 fixtures_path = os.path.join(os.path.dirname(__file__), 'test_exports.json')
 fixtures = json.loads(open(fixtures_path, 'r').read())
 
-engines = [
-    'bs',
-    'lxml',
-]
-
 exporter = HTML({
     'entity_decorators': {
         ENTITY_TYPES.LINK: Link,
@@ -67,6 +62,8 @@ class TestExportsMeta(type):
             engine = 'html5lib'
         elif name == 'TestExportsLXML':
             engine = 'lxml'
+        elif name == 'TestExportsSTRING':
+            engine = 'string'
 
         for export in fixtures:
             test_label = export['label'].lower().replace(' ', '_')
@@ -109,5 +106,21 @@ class TestExportsLXML(six.with_metaclass(TestExportsMeta, unittest.TestCase)):
         self.assertIsInstance(exporter, HTML)
 
 
+class TestExportsSTRING(six.with_metaclass(TestExportsMeta, unittest.TestCase)):
+    @classmethod
+    def setUpClass(cls):
+        cls.pr = cProfile.Profile()
+        cls.pr.enable()
+        print('\nstring')
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.pr.disable()
+        Stats(cls.pr).strip_dirs().sort_stats('cumulative').print_stats(0)
+
+    def test_init(self):
+        self.assertIsInstance(exporter, HTML)
+
+
 if __name__ == "__main__":
     unittest.main()