Repository: springload/draftjs_exporter

Issue:
style_map components should be given data on render
At the moment, `style_map` components do not receive any data beyond the text to style (as `props['children]`).

https://github.com/springload/draftjs_exporter/blob/209631a107be74837b1be2f7c0697f77d64d994b/draftjs_exporter/style_state.py#L26-L32

This is ok for common use cases (`BOLD`, `ITALIC`, etc), but it makes the style_map fallback rather useless – there is no way to know what style needs the fallback, or have any other information about the context to adjust the fallback behavior.

Here's what the `block_map` fallback has access to for comparison:

```py
props['block'] = {
        'type': type_,
        'depth': depth,
        'data': data,
}
```

In retrospect I think this could've been all of the block's attributes, not just a cherry-picked shortlist, so for inline styles we could pass the following exhaustive `props`:

```py
{
    # The style range to render.
    "range": 
        "offset": 10,
        "length": 17,
        "style": "BOLD"
    },
    # The full block data, eg.
    "block": {
            "key": "t7k7",
            "text": "Unstyled test test test test test",
            "type": "unstyled",
            "depth": 0,
            "inlineStyleRanges": [
                {
                    "offset": 10,
                    "length": 17,
                    "style": "BOLD"
                }
            ],
            "entityRanges": [
                {
                    "offset": 0,
                    "length": 4,
                    "key": 6
                }
            ],
            "data": {}
        },
}
```

Here's the approximative change:

```diff
-    def render_styles(self, text_node):
+    def render_styles(self, text_node, block):
        node = text_node
        if not self.is_empty():
            # Nest the tags.
            for s in sorted(self.styles, reverse=True):
                opt = Options.for_style(self.style_map, s)
+                props['block'] = block
+                props['range'] = s
                node = DOM.create_element(opt.element, opt.props, node)

        return node
```

---

Ideally I'd like entities and blocks to also be given more data (enough data to recreate the whole ContentState, thus making the exporter usable to create content migrations), but that's a separate issue.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f70acb..2db755a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@
 
 ## Unreleased
 
+### Added
+
+* Give block rendering components access to the current `block`, when the component is rendered for a block, and the `blocks` list.
+* Give text decorators renderers access to the current `block` and `blocks` list.
+* Give style rendering components access to the current `block`, `blocks` list, and current style type as `inline_style_range.style` ([#87](https://github.com/springload/draftjs_exporter/issues/87)).
+
 ### Changed
 
 * Performance improvements for text-only (no inline styles, no entities) blocks.
diff --git a/draftjs_exporter/composite_decorators.py b/draftjs_exporter/composite_decorators.py
index 8080fd5..5f167f9 100644
--- a/draftjs_exporter/composite_decorators.py
+++ b/draftjs_exporter/composite_decorators.py
@@ -21,7 +21,7 @@ def get_decorations(decorators, text):
     return decorations
 
 
-def apply_decorators(decorators, text, block):
+def apply_decorators(decorators, text, block, blocks):
     decorations = get_decorations(decorators, text)
 
     pointer = 0
@@ -31,11 +31,8 @@ def apply_decorators(decorators, text, block):
 
         yield DOM.create_element(decorator['component'], {
             'match': match,
-            'block': {
-                'type': block['type'],
-                'depth': block['depth'],
-                'data': block.get('data', {}),
-            }
+            'block': block,
+            'blocks': blocks,
         }, match.group(0))
         pointer = end
 
@@ -43,8 +40,8 @@ def apply_decorators(decorators, text, block):
         yield text[pointer:]
 
 
-def render_decorators(decorators, text, block):
-    decorated_children = list(apply_decorators(decorators, text, block))
+def render_decorators(decorators, text, block, blocks):
+    decorated_children = list(apply_decorators(decorators, text, block, blocks))
 
     if len(decorated_children) == 1:
         decorated_node = decorated_children[0]
diff --git a/draftjs_exporter/dom.py b/draftjs_exporter/dom.py
index 967253b..9d67333 100644
--- a/draftjs_exporter/dom.py
+++ b/draftjs_exporter/dom.py
@@ -76,7 +76,9 @@ class DOM(object):
             # Never render those attributes on a raw tag.
             props.pop('children', None)
             props.pop('block', None)
+            props.pop('blocks', None)
             props.pop('entity', None)
+            props.pop('inline_style_range', None)
 
             # Convert style object to style string, like the DOM would do.
             if 'style' in props and isinstance(props['style'], dict):
diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py
index e527c2b..5267bd7 100644
--- a/draftjs_exporter/html.py
+++ b/draftjs_exporter/html.py
@@ -34,12 +34,13 @@ class HTML:
         if content_state is None:
             content_state = {}
 
-        wrapper_state = WrapperState(self.block_map)
+        blocks = content_state.get('blocks', [])
+        wrapper_state = WrapperState(self.block_map, blocks)
         document = DOM.create_element()
         entity_map = content_state.get('entityMap', {})
         min_depth = 0
 
-        for block in content_state.get('blocks', []):
+        for block in blocks:
             depth = block['depth']
             elt = self.render_block(block, entity_map, wrapper_state)
 
@@ -70,11 +71,11 @@ class HTML:
 
                 # Decorators are not rendered inside entities.
                 if text and entity_state.has_no_entity() and len(self.composite_decorators) > 0:
-                    decorated_node = render_decorators(self.composite_decorators, text, block)
+                    decorated_node = render_decorators(self.composite_decorators, text, block, wrapper_state.blocks)
                 else:
                     decorated_node = text
 
-                styled_node = style_state.render_styles(decorated_node)
+                styled_node = style_state.render_styles(decorated_node, block, wrapper_state.blocks)
                 entity_node = entity_state.render_entities(styled_node)
 
                 if entity_node is not None:
@@ -84,7 +85,7 @@ class HTML:
         # Fast track for blocks which do not contain styles nor entities, which is very common.
         else:
             if len(self.composite_decorators) > 0:
-                decorated_node = render_decorators(self.composite_decorators, block['text'], block)
+                decorated_node = render_decorators(self.composite_decorators, block['text'], block, wrapper_state.blocks)
             else:
                 decorated_node = block['text']
 
diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py
index d6fad23..e0c5a35 100644
--- a/draftjs_exporter/style_state.py
+++ b/draftjs_exporter/style_state.py
@@ -23,12 +23,18 @@ class StyleState:
     def is_empty(self):
         return not self.styles
 
-    def render_styles(self, text_node):
-        node = text_node
+    def render_styles(self, decorated_node, block, blocks):
+        node = decorated_node
         if not self.is_empty():
             # Nest the tags.
-            for s in sorted(self.styles, reverse=True):
-                opt = Options.for_style(self.style_map, s)
-                node = DOM.create_element(opt.element, opt.props, node)
+            for style in sorted(self.styles, reverse=True):
+                opt = Options.for_style(self.style_map, style)
+                props = dict(opt.props)
+                props['block'] = block
+                props['blocks'] = blocks
+                props['inline_style_range'] = {
+                    'style': style,
+                }
+                node = DOM.create_element(opt.element, props, node)
 
         return node
diff --git a/draftjs_exporter/wrapper_state.py b/draftjs_exporter/wrapper_state.py
index e566550..ac5312b 100644
--- a/draftjs_exporter/wrapper_state.py
+++ b/draftjs_exporter/wrapper_state.py
@@ -79,8 +79,9 @@ class WrapperState:
     It adds a wrapper element around elements, if required.
     """
 
-    def __init__(self, block_map):
+    def __init__(self, block_map, blocks):
         self.block_map = block_map
+        self.blocks = blocks
         self.stack = WrapperStack()
 
     def __str__(self):
@@ -89,14 +90,10 @@ class WrapperState:
     def element_for(self, block, block_content):
         type_ = block['type']
         depth = block['depth']
-        data = block.get('data', {})
         options = Options.for_block(self.block_map, type_)
         props = dict(options.props)
-        props['block'] = {
-            'type': type_,
-            'depth': depth,
-            'data': data,
-        }
+        props['block'] = block
+        props['blocks'] = self.blocks
 
         # Make an element from the options specified in the block map.
         elt = DOM.create_element(options.element, props, block_content)
@@ -146,6 +143,8 @@ class WrapperState:
                         'depth': depth,
                         'data': {},
                     }
+                    props['blocks'] = self.blocks
+
                     wrapper_parent = DOM.create_element(options.element, props)
                     DOM.append_child(self.stack.head().elt, wrapper_parent)
                 else:
diff --git a/example.py b/example.py
index 6fa442b..8fc9022 100644
--- a/example.py
+++ b/example.py
@@ -11,7 +11,7 @@ from pstats import Stats
 from bs4 import BeautifulSoup
 
 # draftjs_exporter provides default configurations and predefined constants for reuse.
-from draftjs_exporter.constants import BLOCK_TYPES, ENTITY_TYPES
+from draftjs_exporter.constants import BLOCK_TYPES, ENTITY_TYPES, INLINE_STYLES
 from draftjs_exporter.defaults import BLOCK_MAP, STYLE_MAP
 from draftjs_exporter.dom import DOM
 from draftjs_exporter.html import HTML
@@ -127,6 +127,12 @@ def entity_fallback(props):
     return DOM.create_element('span', {'class': 'missing-entity'}, props['children'])
 
 
+def style_fallback(props):
+    type_ = props['inline_style_range']['style']
+    logging.warn('Missing config for "%s". Deleting style.' % type_)
+    return props['children']
+
+
 if __name__ == '__main__':
     config = {
         # `block_map` is a mapping from Draft.js block types to a definition of their HTML representation.
@@ -158,6 +164,7 @@ if __name__ == '__main__':
             'KBD': 'kbd',
             # The `style` prop can be defined as a dict, that will automatically be converted to a string.
             'HIGHLIGHT': {'element': 'strong', 'props': {'style': {'textDecoration': 'underline'}}},
+            INLINE_STYLES.FALLBACK: style_fallback,
         }),
         'entity_decorators': {
             # Map entities to components so they can be rendered with their data.
@@ -565,7 +572,11 @@ if __name__ == '__main__':
             "text": "Optionally, define your custom components.",
             "type": "ordered-list-item",
             "depth": 1,
-            "inlineStyleRanges": [],
+            "inlineStyleRanges": [{
+                "offset": 0,
+                "length": 10,
+                "style": "EXAMPLE_DISCARD"
+            }],
             "entityRanges": [],
             "data": {}
         }, {


Test patch:
diff --git a/tests/test_composite_decorators.py b/tests/test_composite_decorators.py
index aeb029c..e375ebe 100644
--- a/tests/test_composite_decorators.py
+++ b/tests/test_composite_decorators.py
@@ -68,13 +68,37 @@ class TestBR(unittest.TestCase):
 
 class TestCompositeDecorators(unittest.TestCase):
     def test_render_decorators_empty(self):
-        self.assertEqual(render_decorators([], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0}), 'test https://www.example.com#hash #hashtagtest')
+        self.assertEqual(render_decorators([], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0}, []), 'test https://www.example.com#hash #hashtagtest')
 
     def test_render_decorators_single(self):
-        self.assertEqual(DOM.render(render_decorators([LINKIFY_DECORATOR], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0})), 'test <a href="https://www.example.com#hash">https://www.example.com#hash</a> #hashtagtest')
+        self.assertEqual(DOM.render(render_decorators([LINKIFY_DECORATOR], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0}, [])), 'test <a href="https://www.example.com#hash">https://www.example.com#hash</a> #hashtagtest')
 
     def test_render_decorators_conflicting_order_one(self):
-        self.assertEqual(DOM.render(render_decorators([LINKIFY_DECORATOR, HASHTAG_DECORATOR], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0})), 'test <a href="https://www.example.com#hash">https://www.example.com#hash</a> <span class="hashtag">#hashtagtest</span>')
+        self.assertEqual(DOM.render(render_decorators([LINKIFY_DECORATOR, HASHTAG_DECORATOR], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0}, [])), 'test <a href="https://www.example.com#hash">https://www.example.com#hash</a> <span class="hashtag">#hashtagtest</span>')
 
     def test_render_decorators_conflicting_order_two(self):
-        self.assertEqual(DOM.render(render_decorators([HASHTAG_DECORATOR, LINKIFY_DECORATOR], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0})), 'test https://www.example.com<span class="hashtag">#hash</span> <span class="hashtag">#hashtagtest</span>')
+        self.assertEqual(DOM.render(render_decorators([HASHTAG_DECORATOR, LINKIFY_DECORATOR], 'test https://www.example.com#hash #hashtagtest', {'type': BLOCK_TYPES.UNSTYLED, 'depth': 0}, [])), 'test https://www.example.com<span class="hashtag">#hash</span> <span class="hashtag">#hashtagtest</span>')
+
+    def test_render_decorators_data(self):
+        blocks = [
+            {
+                'key': '5s7g9',
+                'text': 'test',
+                'type': 'unstyled',
+                'depth': 0,
+                'inlineStyleRanges': [],
+                'entityRanges': [],
+            },
+        ]
+
+        def component(props):
+            self.assertEqual(props['blocks'], blocks)
+            self.assertEqual(props['block'], blocks[0])
+            return None
+
+        render_decorators([
+            {
+                'strategy': LINKIFY_RE,
+                'component': component,
+            },
+        ], 'test https://www.example.com#hash #hashtagtest', blocks[0], blocks)
diff --git a/tests/test_style_state.py b/tests/test_style_state.py
index 44b3120..20b36ac 100644
--- a/tests/test_style_state.py
+++ b/tests/test_style_state.py
@@ -55,41 +55,67 @@ class TestStyleState(unittest.TestCase):
         self.assertEqual(self.style_state.is_empty(), False)
 
     def test_render_styles_unstyled(self):
-        self.assertEqual(self.style_state.render_styles('Test text'), 'Test text')
+        self.assertEqual(self.style_state.render_styles('Test text', {}, []), 'Test text')
 
     def test_render_styles_unicode(self):
-        self.assertEqual(self.style_state.render_styles('🍺'), '🍺')
+        self.assertEqual(self.style_state.render_styles('🍺', {}, []), '🍺')
 
     def test_render_styles_styled(self):
         self.style_state.apply(Command('start_inline_style', 0, 'ITALIC'))
-        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text')), '<em>Test text</em>')
+        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text', {}, [])), '<em>Test text</em>')
         self.style_state.apply(Command('stop_inline_style', 9, 'ITALIC'))
 
     def test_render_styles_styled_multiple(self):
         self.style_state.apply(Command('start_inline_style', 0, 'BOLD'))
         self.style_state.apply(Command('start_inline_style', 0, 'ITALIC'))
-        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text')), '<strong><em>Test text</em></strong>')
+        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text', {}, [])), '<strong><em>Test text</em></strong>')
 
     def test_render_styles_attributes(self):
         self.style_state.apply(Command('start_inline_style', 0, 'KBD'))
-        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text')), '<kbd class="o-keyboard-shortcut">Test text</kbd>')
+        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text', {}, [])), '<kbd class="o-keyboard-shortcut">Test text</kbd>')
         self.style_state.apply(Command('stop_inline_style', 9, 'KBD'))
 
     def test_render_styles_component(self):
         self.style_state.apply(Command('start_inline_style', 0, 'IMPORTANT'))
-        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text')), '<strong style="color: red;">Test text</strong>')
+        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text', {}, [])), '<strong style="color: red;">Test text</strong>')
         self.style_state.apply(Command('stop_inline_style', 9, 'IMPORTANT'))
 
     def test_render_styles_component_multiple(self):
         self.style_state.apply(Command('start_inline_style', 0, 'IMPORTANT'))
         self.style_state.apply(Command('start_inline_style', 0, 'SHOUT'))
-        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text')), '<strong style="color: red;"><span style="text-transform: uppercase;">Test text</span></strong>')
+        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text', {}, [])), '<strong style="color: red;"><span style="text-transform: uppercase;">Test text</span></strong>')
         self.style_state.apply(Command('stop_inline_style', 9, 'IMPORTANT'))
         self.style_state.apply(Command('stop_inline_style', 9, 'SHOUT'))
 
     def test_render_styles_component_multiple_invert(self):
         self.style_state.apply(Command('start_inline_style', 0, 'SHOUT'))
         self.style_state.apply(Command('start_inline_style', 0, 'IMPORTANT'))
-        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text')), '<strong style="color: red;"><span style="text-transform: uppercase;">Test text</span></strong>')
+        self.assertEqual(DOM.render_debug(self.style_state.render_styles('Test text', {}, [])), '<strong style="color: red;"><span style="text-transform: uppercase;">Test text</span></strong>')
         self.style_state.apply(Command('stop_inline_style', 9, 'SHOUT'))
         self.style_state.apply(Command('stop_inline_style', 9, 'IMPORTANT'))
+
+    def test_render_styles_data(self):
+        blocks = [
+            {
+                'key': '5s7g9',
+                'text': 'test',
+                'type': 'unstyled',
+                'depth': 0,
+                'inlineStyleRanges': [],
+                'entityRanges': [],
+            },
+        ]
+
+        def component(props):
+            self.assertEqual(props['blocks'], blocks)
+            self.assertEqual(props['block'], blocks[0])
+            self.assertEqual(props['inline_style_range']['style'], 'ITALIC')
+            return None
+
+        style_state = StyleState({
+            'ITALIC': component,
+        })
+
+        style_state.apply(Command('start_inline_style', 0, 'ITALIC'))
+        style_state.render_styles('Test text', blocks[0], blocks)
+        style_state.apply(Command('stop_inline_style', 9, 'ITALIC'))
diff --git a/tests/test_wrapper_state.py b/tests/test_wrapper_state.py
index 8d2cebe..80b2daf 100644
--- a/tests/test_wrapper_state.py
+++ b/tests/test_wrapper_state.py
@@ -21,11 +21,29 @@ class TestWrapperState(unittest.TestCase):
                 'element': list_item,
                 'wrapper': ordered_list
             },
-        })
+        }, [])
 
     def test_init(self):
         self.assertIsInstance(self.wrapper_state, WrapperState)
 
+    def test_element_for_data(self):
+        blocks = [
+            {
+                'key': '5s7g9',
+                'text': 'test',
+                'type': 'unstyled',
+                'depth': 0,
+                'inlineStyleRanges': [],
+                'entityRanges': [],
+            },
+        ]
+
+        def unstyled(props):
+            self.assertEqual(props['blocks'], blocks)
+            self.assertEqual(props['block'], blocks[0])
+
+        WrapperState({'unstyled': unstyled}, blocks).element_for(blocks[0], 'test')
+
     def test_element_for_simple_content(self):
         self.assertEqual(DOM.render_debug(self.wrapper_state.element_for({
             'key': '5s7g9',