From 5be127af5d0cf66ca4cd453384a6963c06f7279e Mon Sep 17 00:00:00 2001 From: sudan Date: Mon, 31 Oct 2016 12:34:48 +0800 Subject: [PATCH 01/13] Implement composite decorators (#17) Initial work by @su27 in PR #17, then rebased and refactored. - add composite decorator - fix link decorator - Implement decorator with DOM - decorators should know the current block type - fix composite decorator --- draftjs_exporter/composite_decorator.py | 30 +++++ draftjs_exporter/html.py | 8 +- draftjs_exporter/style_state.py | 33 ++---- example.py | 50 +++++++- tests/test_composite_decorator.py | 146 ++++++++++++++++++++++++ tests/test_style_state.py | 2 +- 6 files changed, 243 insertions(+), 26 deletions(-) create mode 100644 draftjs_exporter/composite_decorator.py create mode 100644 tests/test_composite_decorator.py diff --git a/draftjs_exporter/composite_decorator.py b/draftjs_exporter/composite_decorator.py new file mode 100644 index 0000000..0b9fbfd --- /dev/null +++ b/draftjs_exporter/composite_decorator.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import, unicode_literals + +from operator import itemgetter +from draftjs_exporter.dom import DOM + + +def get_decorations(decorators, text, block=None): + + block_type = block.get('type') if block else None + occupied = {} + decorations = [] + + for deco in decorators: + for match in deco.SEARCH_RE.finditer(text): + begin, end = match.span() + if not any(occupied.get(i) for i in xrange(begin, end)): + for i in xrange(begin, end): + occupied[i] = 1 + decorations.append((begin, end, match, deco)) + + decorations.sort(key=itemgetter(0)) + pointer = 0 + for begin, end, match, deco in decorations: + if pointer < begin: + yield DOM.create_text_node(text[pointer:begin]) + yield deco.replace(match, block_type) + pointer = end + + if pointer < len(text): + yield DOM.create_text_node(text[pointer:]) diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py index b1e7ba9..c8047c8 100644 --- a/draftjs_exporter/html.py +++ b/draftjs_exporter/html.py @@ -19,13 +19,14 @@ def __init__(self, config=None): self.entity_decorators = config.get('entity_decorators', {}) self.block_map = config.get('block_map', BLOCK_MAP) self.style_map = config.get('style_map', STYLE_MAP) + self.composite_decorators = config.get('composite_decorators', []) def render(self, content_state): """ Starts the export process on a given piece of content state. """ self.wrapper_state = WrapperState(self.block_map) - self.style_state = StyleState(self.style_map) + self.style_state = StyleState(self.style_map, self.composite_decorators) entity_map = content_state.get('entityMap', {}) for block in content_state.get('blocks', []): @@ -44,7 +45,10 @@ def render_block(self, block, entity_map): entity_state.apply(command) self.style_state.apply(command) - style_node = self.style_state.create_node(text) + style_node = self.style_state.create_node( + text, + block=block, + entity_stack=entity_state.entity_stack) entity_state.render_entitities(element, style_node) def build_command_groups(self, block): diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py index f708a99..251148a 100644 --- a/draftjs_exporter/style_state.py +++ b/draftjs_exporter/style_state.py @@ -3,6 +3,7 @@ import re from draftjs_exporter.dom import DOM +from draftjs_exporter.composite_decorator import get_decorations # TODO Extract to utils # https://gist.github.com/yahyaKacem/8170675 @@ -22,9 +23,10 @@ class StyleState: Receives inline_style commands, and generates the element's `style` attribute from those. """ - def __init__(self, style_map): + def __init__(self, style_map, composite_decorators=None): self.styles = [] self.style_map = style_map + self.composite_decorators = composite_decorators or [] def apply(self, command): if command.name == 'start_inline_style': @@ -55,27 +57,16 @@ def get_style_value(self): return ''.join(sorted(rules)) - def replace_linebreaks(self, text): - lines = text.split('\n') - - if len(lines) > 1: - wrapper = DOM.create_document_fragment() - - DOM.append_child(wrapper, DOM.create_text_node(lines[0])) - - for l in lines[1:]: - DOM.append_child(wrapper, DOM.create_element('br')) - DOM.append_child(wrapper, DOM.create_text_node(l)) + def create_node(self, text, block=None, entity_stack=None): + if entity_stack: + text_children = [DOM.create_text_node(text)] else: - wrapper = DOM.create_text_node(text) - - return wrapper - - def create_node(self, text): - text_lines = self.replace_linebreaks(text) + text_children = get_decorations(self.composite_decorators, text, block) if self.is_unstyled(): - node = text_lines + node = DOM.create_document_fragment() + for child in text_children: + DOM.append_child(node, child) else: tags = self.get_style_tags() node = DOM.create_element(tags[0]) @@ -91,7 +82,7 @@ def create_node(self, text): style_value = self.get_style_value() if style_value: DOM.set_attribute(child, 'style', style_value) - - DOM.append_child(child, text_lines) + for text_child in text_children: + DOM.append_child(child, text_child) return node diff --git a/example.py b/example.py index 7a6c0f1..33556ed 100644 --- a/example.py +++ b/example.py @@ -2,7 +2,9 @@ from __future__ import absolute_import, unicode_literals +import cgi import codecs +import re from draftjs_exporter.constants import BLOCK_TYPES, ENTITY_TYPES from draftjs_exporter.defaults import BLOCK_MAP, STYLE_MAP @@ -35,12 +37,56 @@ def render(self, props): return DOM.create_element('a', {'href': href}, props['children']) +class URLDecorator: + """ + Replace plain urls with actual hyperlinks. + """ + SEARCH_RE = re.compile(r'(http://|https://|www\.)([a-zA-Z0-9\.\-%/\?&_=\+#:~!,\'\*\^$]+)') + + def __init__(self, new_window=False): + self.new_window = new_window + + def replace(self, match, block_type): + protocol = match.group(1) + href = match.group(2) + href = protocol + href + if block_type == BLOCK_TYPES.CODE: + return href + + text = cgi.escape(href) + if href.startswith("www"): + href = "http://" + href + props = {'href': href} + if self.new_window: + props.update(target="_blank") + + return DOM.create_element('a', props, text) + + +class HashTagDecorator: + """ + Wrap hash tags in spans with specific class. + """ + + SEARCH_RE = re.compile(r'#\w+') + + def replace(self, match, block_type): + if block_type == BLOCK_TYPES.CODE: + return match.group(0) + + return DOM.create_element('em', {'class': 'hash_tag'}, match.group(0)) + + config = { 'entity_decorators': { ENTITY_TYPES.LINK: Link(), ENTITY_TYPES.IMAGE: Image(), ENTITY_TYPES.HORIZONTAL_RULE: HR(), }, + 'composite_decorators': [ + URLDecorator(), + HashTagDecorator(), + ], # Extend/override the default block map. 'block_map': dict(BLOCK_MAP, **{ BLOCK_TYPES.HEADER_TWO: { @@ -104,7 +150,7 @@ def render(self, props): }, { 'key': '5384u', - 'text': 'Everyone 🍺 Springload applies the best principles of UX to their work.', + 'text': 'Everyone 🍺 Springload applies the best #principles of UX to their work. (https://www.springload.co.nz/work/nz-festival/)', 'type': 'blockquote', 'depth': 0, 'inlineStyleRanges': [], @@ -112,7 +158,7 @@ def render(self, props): }, { 'key': 'eelkd', - 'text': 'The design decisions we make building tools and services for your customers are based on empathy for what your customers need.', + 'text': 'The design decisions we make building #tools and #services for your customers are based on empathy for what your customers need.', 'type': 'unstyled', 'depth': 0, 'inlineStyleRanges': [], diff --git a/tests/test_composite_decorator.py b/tests/test_composite_decorator.py new file mode 100644 index 0000000..a22cf29 --- /dev/null +++ b/tests/test_composite_decorator.py @@ -0,0 +1,146 @@ +from __future__ import absolute_import, unicode_literals + +import cgi +import re +import unittest + +from draftjs_exporter.constants import BLOCK_TYPES +from draftjs_exporter.dom import DOM +from draftjs_exporter.html import HTML + +from .test_entities import Link + + +class URLDecorator: + """ + Replace plain urls with actual hyperlinks. + """ + SEARCH_RE = re.compile(r'(http://|https://|www\.)([a-zA-Z0-9\.\-%/\?&_=\+#:~!,\'\*\^$]+)') + + def __init__(self, new_window=False): + self.new_window = new_window + + def replace(self, match, block_type): + protocol = match.group(1) + url = match.group(2) + href = protocol + url + if block_type == BLOCK_TYPES.CODE: + return href + + text = cgi.escape(href) + if href.startswith("www"): + href = "http://" + href + props = {'href': href} + if self.new_window: + props.update(target="_blank") + + return DOM.create_element('a', props, text) + + +class HashTagDecorator: + """ + Wrap hash tags in spans with specific class. + """ + SEARCH_RE = re.compile(r'#\w+') + + def replace(self, match, block_type): + + if block_type == BLOCK_TYPES.CODE: + return match.group(0) + + return DOM.create_element( + 'span', + {'class': 'hash_tag'}, match.group(0) + ) + + +config = { + 'entity_decorators': { + 'LINK': Link() + }, + 'composite_decorators': [ + URLDecorator(), + HashTagDecorator() + ], + 'block_map': { + BLOCK_TYPES.UNSTYLED: {'element': 'div'}, + BLOCK_TYPES.CODE: {'element': 'pre'} + }, + 'style_map': { + 'ITALIC': {'element': 'em'}, + 'BOLD': {'element': 'strong'} + } +} + + +class TestCompositeDecorator(unittest.TestCase): + + def setUp(self): + self.exporter = HTML(config) + self.maxDiff = None + + def test_render_with_entity_and_decorators(self): + """ + The composite decorator should never render text in any entities. + """ + self.assertEqual(self.exporter.render({ + 'entityMap': { + '1': { + 'type': 'LINK', + 'mutability': 'MUTABLE', + 'data': { + 'url': 'http://amazon.us' + } + } + }, + 'blocks': [ + { + 'key': '5s7g9', + 'text': 'search http://a.us or https://yahoo.com or www.google.com for #github and #facebook', + 'type': 'unstyled', + 'depth': 0, + 'inlineStyleRanges': [], + 'entityRanges': [ + { + 'offset': 7, + 'length': 11, + 'key': 1 + } + ], + }, + { + 'key': '34a12', + 'text': '#check www.example.com', + 'type': 'code-block', + 'inlineStyleRanges': [], + }, + ] + }), + '
search http://a.us or ' + 'https://yahoo.com or ' + 'www.google.com for ' + '#github and ' + '#facebook
' + '
#check www.example.com
') + + def test_render_with_multiple_decorators(self): + """ + When multiple decorators match the same part of text, + only the first one should perform the replacement. + """ + self.assertEqual(self.exporter.render({ + 'entityMap': {}, + 'blocks': [ + { + 'key': '5s7g9', + 'text': 'search http://www.google.com#world for the #world', + 'type': 'unstyled', + 'depth': 0, + 'inlineStyleRanges': [], + 'entityRanges': [], + }, + ] + }), + '
search ' + 'http://www.google.com#world for the ' + '#world
') diff --git a/tests/test_style_state.py b/tests/test_style_state.py index dcb1cb7..c3327e2 100644 --- a/tests/test_style_state.py +++ b/tests/test_style_state.py @@ -61,7 +61,7 @@ def test_replace_linebreaks_multiple_breaks(self): self.assertEqual(DOM.render(self.style_state.replace_linebreaks('\nTest\nte\nxt\n')), '
Test
te
xt
') def test_create_node_unstyled(self): - self.assertEqual(DOM.get_tag_name(self.style_state.create_node('Test text')), 'textnode') + self.assertEqual(DOM.get_tag_name(self.style_state.create_node('Test text')), 'fragment') self.assertEqual(DOM.get_text_content(self.style_state.create_node('Test text')), 'Test text') def test_create_node_unicode(self): From 4b34ef8bfeab392e880b2bfda6a16be1b533a559 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 18 Jan 2017 15:47:24 +0200 Subject: [PATCH 02/13] Change line break implementation to use decorators --- draftjs_exporter/composite_decorator.py | 4 ++-- draftjs_exporter/style_state.py | 2 +- example.py | 18 ++++++++++++++++-- tests/test_composite_decorator.py | 14 ++++++++++++++ tests/test_output.py | 8 ++++++-- tests/test_style_state.py | 11 ----------- 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/draftjs_exporter/composite_decorator.py b/draftjs_exporter/composite_decorator.py index 0b9fbfd..4e9c24b 100644 --- a/draftjs_exporter/composite_decorator.py +++ b/draftjs_exporter/composite_decorator.py @@ -13,8 +13,8 @@ def get_decorations(decorators, text, block=None): for deco in decorators: for match in deco.SEARCH_RE.finditer(text): begin, end = match.span() - if not any(occupied.get(i) for i in xrange(begin, end)): - for i in xrange(begin, end): + if not any(occupied.get(i) for i in range(begin, end)): + for i in range(begin, end): occupied[i] = 1 decorations.append((begin, end, match, deco)) diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py index 251148a..dc07e22 100644 --- a/draftjs_exporter/style_state.py +++ b/draftjs_exporter/style_state.py @@ -2,8 +2,8 @@ import re -from draftjs_exporter.dom import DOM from draftjs_exporter.composite_decorator import get_decorations +from draftjs_exporter.dom import DOM # TODO Extract to utils # https://gist.github.com/yahyaKacem/8170675 diff --git a/example.py b/example.py index 33556ed..20d368a 100644 --- a/example.py +++ b/example.py @@ -37,6 +37,19 @@ def render(self, props): return DOM.create_element('a', {'href': href}, props['children']) +class LineBreakDecorator: + """ + Replace line breaks (\n) with br tags. + """ + SEARCH_RE = re.compile(r'\n') + + def replace(self, match, block_type): + if block_type == BLOCK_TYPES.CODE: + return match.group(0) + + return DOM.create_element('br') + + class URLDecorator: """ Replace plain urls with actual hyperlinks. @@ -84,6 +97,7 @@ def replace(self, match, block_type): ENTITY_TYPES.HORIZONTAL_RULE: HR(), }, 'composite_decorators': [ + LineBreakDecorator(), URLDecorator(), HashTagDecorator(), ], @@ -232,8 +246,8 @@ def replace(self, match, block_type): 'inlineStyleRanges': [], 'entityRanges': [ { - 'offset': 0, - 'length': 71, + 'offset': 53, + 'length': 11, 'key': 1 } ] diff --git a/tests/test_composite_decorator.py b/tests/test_composite_decorator.py index a22cf29..0b3a377 100644 --- a/tests/test_composite_decorator.py +++ b/tests/test_composite_decorator.py @@ -54,6 +54,20 @@ def replace(self, match, block_type): ) +class LineBreakDecorator: + """ + Wrap hash tags in spans with specific class. + """ + SEARCH_RE = re.compile(r'\n') + + def replace(self, match, block_type): + + if block_type == BLOCK_TYPES.CODE: + return match.group(0) + + return DOM.create_element('br') + + config = { 'entity_decorators': { 'LINK': Link() diff --git a/tests/test_output.py b/tests/test_output.py index 2881ad1..bc4eb02 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -7,6 +7,7 @@ from draftjs_exporter.defaults import BLOCK_MAP from draftjs_exporter.entity_state import EntityException from draftjs_exporter.html import HTML +from tests.test_composite_decorator import LineBreakDecorator from tests.test_entities import HR, Link config = { @@ -14,6 +15,9 @@ ENTITY_TYPES.LINK: Link(), ENTITY_TYPES.HORIZONTAL_RULE: HR(), }, + 'composite_decorators': [ + LineBreakDecorator(), + ], 'block_map': dict(BLOCK_MAP, **{ BLOCK_TYPES.UNORDERED_LIST_ITEM: { 'element': 'li', @@ -887,7 +891,7 @@ def test_render_with_default_config(self): }), '

some paragraph text

') def test_render_with_line_breaks(self): - self.assertEqual(HTML().render({ + self.assertEqual(self.exporter.render({ 'entityMap': {}, 'blocks': [ { @@ -908,7 +912,7 @@ def test_render_with_line_breaks(self): }), '

some paragraph text
split in half

') def test_render_with_many_line_breaks(self): - self.assertEqual(HTML().render({ + self.assertEqual(self.exporter.render({ 'entityMap': {}, 'blocks': [ { diff --git a/tests/test_style_state.py b/tests/test_style_state.py index c3327e2..5e51677 100644 --- a/tests/test_style_state.py +++ b/tests/test_style_state.py @@ -49,17 +49,6 @@ def test_get_style_value_multiple(self): self.style_state.apply(Command('start_inline_style', 0, 'HIGHLIGHT')) self.assertEqual(self.style_state.get_style_value(), 'text-decoration: underline;') - def test_replace_linebreaks_no_break(self): - self.assertEqual(DOM.get_tag_name(self.style_state.replace_linebreaks('Test text')), 'textnode') - - def test_replace_linebreaks_one_break(self): - self.assertEqual(DOM.get_tag_name(self.style_state.replace_linebreaks('Test\ntext')), 'fragment') - self.assertEqual(DOM.get_tag_name(DOM.get_children(self.style_state.replace_linebreaks('Test\ntext'))[1]), 'br') - - def test_replace_linebreaks_multiple_breaks(self): - self.assertEqual(DOM.get_tag_name(self.style_state.replace_linebreaks('\nTest\nte\nxt\n')), 'fragment') - self.assertEqual(DOM.render(self.style_state.replace_linebreaks('\nTest\nte\nxt\n')), '
Test
te
xt
') - def test_create_node_unstyled(self): self.assertEqual(DOM.get_tag_name(self.style_state.create_node('Test text')), 'fragment') self.assertEqual(DOM.get_text_content(self.style_state.create_node('Test text')), 'Test text') From bcce0b47f484f8caab3ef88173f68bd7d5d60dc7 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 18 Jan 2017 15:55:36 +0200 Subject: [PATCH 03/13] Rename all the things --- ...omposite_decorator.py => composite_decorators.py} | 0 draftjs_exporter/style_state.py | 2 +- example.py | 12 ++++++------ ...ite_decorator.py => test_composite_decorators.py} | 12 ++++++------ tests/test_output.py | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) rename draftjs_exporter/{composite_decorator.py => composite_decorators.py} (100%) rename tests/{test_composite_decorator.py => test_composite_decorators.py} (96%) diff --git a/draftjs_exporter/composite_decorator.py b/draftjs_exporter/composite_decorators.py similarity index 100% rename from draftjs_exporter/composite_decorator.py rename to draftjs_exporter/composite_decorators.py diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py index dc07e22..ea9ef6f 100644 --- a/draftjs_exporter/style_state.py +++ b/draftjs_exporter/style_state.py @@ -2,7 +2,7 @@ import re -from draftjs_exporter.composite_decorator import get_decorations +from draftjs_exporter.composite_decorators import get_decorations from draftjs_exporter.dom import DOM # TODO Extract to utils diff --git a/example.py b/example.py index 20d368a..3f61fe8 100644 --- a/example.py +++ b/example.py @@ -37,7 +37,7 @@ def render(self, props): return DOM.create_element('a', {'href': href}, props['children']) -class LineBreakDecorator: +class BR: """ Replace line breaks (\n) with br tags. """ @@ -50,7 +50,7 @@ def replace(self, match, block_type): return DOM.create_element('br') -class URLDecorator: +class URL: """ Replace plain urls with actual hyperlinks. """ @@ -76,7 +76,7 @@ def replace(self, match, block_type): return DOM.create_element('a', props, text) -class HashTagDecorator: +class Hashtag: """ Wrap hash tags in spans with specific class. """ @@ -97,9 +97,9 @@ def replace(self, match, block_type): ENTITY_TYPES.HORIZONTAL_RULE: HR(), }, 'composite_decorators': [ - LineBreakDecorator(), - URLDecorator(), - HashTagDecorator(), + BR(), + URL(), + Hashtag(), ], # Extend/override the default block map. 'block_map': dict(BLOCK_MAP, **{ diff --git a/tests/test_composite_decorator.py b/tests/test_composite_decorators.py similarity index 96% rename from tests/test_composite_decorator.py rename to tests/test_composite_decorators.py index 0b3a377..97c91cd 100644 --- a/tests/test_composite_decorator.py +++ b/tests/test_composite_decorators.py @@ -11,7 +11,7 @@ from .test_entities import Link -class URLDecorator: +class URL: """ Replace plain urls with actual hyperlinks. """ @@ -37,7 +37,7 @@ def replace(self, match, block_type): return DOM.create_element('a', props, text) -class HashTagDecorator: +class Hashtag: """ Wrap hash tags in spans with specific class. """ @@ -54,7 +54,7 @@ def replace(self, match, block_type): ) -class LineBreakDecorator: +class BR: """ Wrap hash tags in spans with specific class. """ @@ -73,8 +73,8 @@ def replace(self, match, block_type): 'LINK': Link() }, 'composite_decorators': [ - URLDecorator(), - HashTagDecorator() + URL(), + Hashtag() ], 'block_map': { BLOCK_TYPES.UNSTYLED: {'element': 'div'}, @@ -87,7 +87,7 @@ def replace(self, match, block_type): } -class TestCompositeDecorator(unittest.TestCase): +class TestCompositeDecorators(unittest.TestCase): def setUp(self): self.exporter = HTML(config) diff --git a/tests/test_output.py b/tests/test_output.py index bc4eb02..85cf357 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -7,7 +7,7 @@ from draftjs_exporter.defaults import BLOCK_MAP from draftjs_exporter.entity_state import EntityException from draftjs_exporter.html import HTML -from tests.test_composite_decorator import LineBreakDecorator +from tests.test_composite_decorators import BR from tests.test_entities import HR, Link config = { @@ -16,7 +16,7 @@ ENTITY_TYPES.HORIZONTAL_RULE: HR(), }, 'composite_decorators': [ - LineBreakDecorator(), + BR(), ], 'block_map': dict(BLOCK_MAP, **{ BLOCK_TYPES.UNORDERED_LIST_ITEM: { From c6ff42257af66d4d884e2c155ba42c7fd5a84659 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 18 Jan 2017 16:12:00 +0200 Subject: [PATCH 04/13] Start refactoring composite decorators --- draftjs_exporter/composite_decorators.py | 12 +++++++++--- draftjs_exporter/html.py | 7 ++----- draftjs_exporter/style_state.py | 11 ++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/draftjs_exporter/composite_decorators.py b/draftjs_exporter/composite_decorators.py index 4e9c24b..7fb43c6 100644 --- a/draftjs_exporter/composite_decorators.py +++ b/draftjs_exporter/composite_decorators.py @@ -4,9 +4,7 @@ from draftjs_exporter.dom import DOM -def get_decorations(decorators, text, block=None): - - block_type = block.get('type') if block else None +def get_decorations(decorators, text): occupied = {} decorations = [] @@ -19,6 +17,14 @@ def get_decorations(decorators, text, block=None): decorations.append((begin, end, match, deco)) decorations.sort(key=itemgetter(0)) + + return decorations + + +def apply_decorators(decorators, text, block=None): + block_type = block.get('type') if block else None + decorations = get_decorations(decorators, text) + pointer = 0 for begin, end, match, deco in decorations: if pointer < begin: diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py index c8047c8..a298a03 100644 --- a/draftjs_exporter/html.py +++ b/draftjs_exporter/html.py @@ -17,9 +17,9 @@ def __init__(self, config=None): config = {} self.entity_decorators = config.get('entity_decorators', {}) + self.composite_decorators = config.get('composite_decorators', []) self.block_map = config.get('block_map', BLOCK_MAP) self.style_map = config.get('style_map', STYLE_MAP) - self.composite_decorators = config.get('composite_decorators', []) def render(self, content_state): """ @@ -45,10 +45,7 @@ def render_block(self, block, entity_map): entity_state.apply(command) self.style_state.apply(command) - style_node = self.style_state.create_node( - text, - block=block, - entity_stack=entity_state.entity_stack) + style_node = self.style_state.create_node(text, block, entity_state.entity_stack) entity_state.render_entitities(element, style_node) def build_command_groups(self, block): diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py index ea9ef6f..e3f3d97 100644 --- a/draftjs_exporter/style_state.py +++ b/draftjs_exporter/style_state.py @@ -2,7 +2,7 @@ import re -from draftjs_exporter.composite_decorators import get_decorations +from draftjs_exporter.composite_decorators import apply_decorators from draftjs_exporter.dom import DOM # TODO Extract to utils @@ -61,13 +61,14 @@ def create_node(self, text, block=None, entity_stack=None): if entity_stack: text_children = [DOM.create_text_node(text)] else: - text_children = get_decorations(self.composite_decorators, text, block) + text_children = apply_decorators(self.composite_decorators, text, block) if self.is_unstyled(): node = DOM.create_document_fragment() for child in text_children: DOM.append_child(node, child) else: + style = self.get_style_value() tags = self.get_style_tags() node = DOM.create_element(tags[0]) child = node @@ -79,9 +80,9 @@ def create_node(self, text, block=None, entity_stack=None): DOM.append_child(child, new_child) child = new_child - style_value = self.get_style_value() - if style_value: - DOM.set_attribute(child, 'style', style_value) + if style: + DOM.set_attribute(child, 'style', style) + for text_child in text_children: DOM.append_child(child, text_child) From 9d3ddf7debb4e2f3cd90e58bcb54daa5a86e9ff5 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 18 Jan 2017 16:22:50 +0200 Subject: [PATCH 05/13] Pass block_type to style_state instead of whole block --- draftjs_exporter/composite_decorators.py | 3 +-- draftjs_exporter/html.py | 2 +- draftjs_exporter/style_state.py | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/draftjs_exporter/composite_decorators.py b/draftjs_exporter/composite_decorators.py index 7fb43c6..4f81f9a 100644 --- a/draftjs_exporter/composite_decorators.py +++ b/draftjs_exporter/composite_decorators.py @@ -21,8 +21,7 @@ def get_decorations(decorators, text): return decorations -def apply_decorators(decorators, text, block=None): - block_type = block.get('type') if block else None +def apply_decorators(decorators, text, block_type=None): decorations = get_decorations(decorators, text) pointer = 0 diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py index a298a03..5c6439b 100644 --- a/draftjs_exporter/html.py +++ b/draftjs_exporter/html.py @@ -45,7 +45,7 @@ def render_block(self, block, entity_map): entity_state.apply(command) self.style_state.apply(command) - style_node = self.style_state.create_node(text, block, entity_state.entity_stack) + style_node = self.style_state.create_node(text, block.get('type', None), entity_state.entity_stack) entity_state.render_entitities(element, style_node) def build_command_groups(self, block): diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py index e3f3d97..18d95c6 100644 --- a/draftjs_exporter/style_state.py +++ b/draftjs_exporter/style_state.py @@ -57,11 +57,11 @@ def get_style_value(self): return ''.join(sorted(rules)) - def create_node(self, text, block=None, entity_stack=None): + def create_node(self, text, block_type=None, entity_stack=None): if entity_stack: text_children = [DOM.create_text_node(text)] else: - text_children = apply_decorators(self.composite_decorators, text, block) + text_children = apply_decorators(self.composite_decorators, text, block_type) if self.is_unstyled(): node = DOM.create_document_fragment() From 9e8fbfc9facad38b53f5f8bbd86596d53bcdf6af Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 18 Jan 2017 17:20:05 +0200 Subject: [PATCH 06/13] Remove all decorator logic from style_state --- draftjs_exporter/composite_decorators.py | 2 +- draftjs_exporter/html.py | 16 +++++++++++++--- draftjs_exporter/style_state.py | 18 ++++-------------- tests/test_style_state.py | 24 ++++++++++++------------ 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/draftjs_exporter/composite_decorators.py b/draftjs_exporter/composite_decorators.py index 4f81f9a..1a7aeb6 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_type=None): +def apply_decorators(decorators, text, block_type): decorations = get_decorations(decorators, text) pointer = 0 diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py index 5c6439b..28891bf 100644 --- a/draftjs_exporter/html.py +++ b/draftjs_exporter/html.py @@ -1,7 +1,9 @@ from __future__ import absolute_import, unicode_literals from draftjs_exporter.command import Command +from draftjs_exporter.composite_decorators import apply_decorators from draftjs_exporter.defaults import BLOCK_MAP, STYLE_MAP +from draftjs_exporter.dom import DOM from draftjs_exporter.entity_state import EntityState from draftjs_exporter.style_state import StyleState from draftjs_exporter.wrapper_state import WrapperState @@ -26,7 +28,7 @@ def render(self, content_state): Starts the export process on a given piece of content state. """ self.wrapper_state = WrapperState(self.block_map) - self.style_state = StyleState(self.style_map, self.composite_decorators) + self.style_state = StyleState(self.style_map) entity_map = content_state.get('entityMap', {}) for block in content_state.get('blocks', []): @@ -45,8 +47,16 @@ def render_block(self, block, entity_map): entity_state.apply(command) self.style_state.apply(command) - style_node = self.style_state.create_node(text, block.get('type', None), entity_state.entity_stack) - entity_state.render_entitities(element, style_node) + if entity_state.entity_stack: + decorated_node = DOM.create_text_node(text) + else: + decorated_node = DOM.create_document_fragment() + + for decorated_child in apply_decorators(self.composite_decorators, text, block.get('type', None)): + DOM.append_child(decorated_node, decorated_child) + + styled_node = self.style_state.render_styles(decorated_node) + entity_state.render_entitities(element, styled_node) def build_command_groups(self, block): """ diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py index 18d95c6..2105b97 100644 --- a/draftjs_exporter/style_state.py +++ b/draftjs_exporter/style_state.py @@ -2,7 +2,6 @@ import re -from draftjs_exporter.composite_decorators import apply_decorators from draftjs_exporter.dom import DOM # TODO Extract to utils @@ -23,10 +22,9 @@ class StyleState: Receives inline_style commands, and generates the element's `style` attribute from those. """ - def __init__(self, style_map, composite_decorators=None): + def __init__(self, style_map): self.styles = [] self.style_map = style_map - self.composite_decorators = composite_decorators or [] def apply(self, command): if command.name == 'start_inline_style': @@ -57,16 +55,9 @@ def get_style_value(self): return ''.join(sorted(rules)) - def create_node(self, text, block_type=None, entity_stack=None): - if entity_stack: - text_children = [DOM.create_text_node(text)] - else: - text_children = apply_decorators(self.composite_decorators, text, block_type) - + def render_styles(self, text_node): if self.is_unstyled(): - node = DOM.create_document_fragment() - for child in text_children: - DOM.append_child(node, child) + node = text_node else: style = self.get_style_value() tags = self.get_style_tags() @@ -83,7 +74,6 @@ def create_node(self, text, block_type=None, entity_stack=None): if style: DOM.set_attribute(child, 'style', style) - for text_child in text_children: - DOM.append_child(child, text_child) + DOM.append_child(child, text_node) return node diff --git a/tests/test_style_state.py b/tests/test_style_state.py index 5e51677..37646a0 100644 --- a/tests/test_style_state.py +++ b/tests/test_style_state.py @@ -49,23 +49,23 @@ def test_get_style_value_multiple(self): self.style_state.apply(Command('start_inline_style', 0, 'HIGHLIGHT')) self.assertEqual(self.style_state.get_style_value(), 'text-decoration: underline;') - def test_create_node_unstyled(self): - self.assertEqual(DOM.get_tag_name(self.style_state.create_node('Test text')), 'fragment') - self.assertEqual(DOM.get_text_content(self.style_state.create_node('Test text')), 'Test text') + def test_render_styles_unstyled(self): + self.assertEqual(DOM.get_tag_name(self.style_state.render_styles(DOM.create_text_node('Test text'))), 'textnode') + self.assertEqual(DOM.get_text_content(self.style_state.render_styles(DOM.create_text_node('Test text'))), 'Test text') - def test_create_node_unicode(self): - self.assertEqual(DOM.get_text_content(self.style_state.create_node('🍺')), '🍺') + def test_render_styles_unicode(self): + self.assertEqual(DOM.get_text_content(self.style_state.render_styles(DOM.create_text_node('🍺'))), '🍺') - def test_create_node_styled(self): + def test_render_styles_styled(self): self.style_state.apply(Command('start_inline_style', 0, 'ITALIC')) - self.assertEqual(DOM.get_tag_name(self.style_state.create_node('Test text')), 'em') - self.assertEqual(self.style_state.create_node('Test text').get('style'), None) - self.assertEqual(DOM.get_text_content(self.style_state.create_node('Test text')), 'Test text') + self.assertEqual(DOM.get_tag_name(self.style_state.render_styles(DOM.create_text_node('Test text'))), 'em') + self.assertEqual(self.style_state.render_styles(DOM.create_text_node('Test text')).get('style'), None) + self.assertEqual(DOM.get_text_content(self.style_state.render_styles(DOM.create_text_node('Test text'))), 'Test text') self.style_state.apply(Command('stop_inline_style', 9, 'ITALIC')) - def test_create_node_styled_multiple(self): + 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(self.style_state.get_style_tags(), ['em', 'strong']) - self.assertEqual(DOM.get_tag_name(self.style_state.create_node('wow')), 'em') - self.assertEqual(DOM.get_tag_name(DOM.get_children(self.style_state.create_node('wow'))[0]), 'strong') + self.assertEqual(DOM.get_tag_name(self.style_state.render_styles(DOM.create_text_node('Test text'))), 'em') + self.assertEqual(DOM.get_tag_name(DOM.get_children(self.style_state.render_styles(DOM.create_text_node('Test text')))[0]), 'strong') From 06892cce26aaea8ad63f75901932bd1c050cc137 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 18 Jan 2017 17:39:26 +0200 Subject: [PATCH 07/13] Finish splitting composite decorators from entities and styles --- draftjs_exporter/composite_decorators.py | 9 +++++++++ draftjs_exporter/entity_state.py | 5 ++++- draftjs_exporter/html.py | 12 +++++------- draftjs_exporter/style_state.py | 4 ++-- tests/test_entity_state.py | 7 +++++++ tests/test_style_state.py | 8 ++++---- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/draftjs_exporter/composite_decorators.py b/draftjs_exporter/composite_decorators.py index 1a7aeb6..a3fb1d0 100644 --- a/draftjs_exporter/composite_decorators.py +++ b/draftjs_exporter/composite_decorators.py @@ -33,3 +33,12 @@ def apply_decorators(decorators, text, block_type): if pointer < len(text): yield DOM.create_text_node(text[pointer:]) + + +def render_decorators(decorators, text, block_type): + decorated_node = DOM.create_document_fragment() + + for decorated_child in apply_decorators(decorators, text, block_type): + DOM.append_child(decorated_node, decorated_child) + + return decorated_node diff --git a/draftjs_exporter/entity_state.py b/draftjs_exporter/entity_state.py index 4a57353..8a8c4dc 100644 --- a/draftjs_exporter/entity_state.py +++ b/draftjs_exporter/entity_state.py @@ -21,6 +21,9 @@ def apply(self, command): elif command.name == 'stop_entity': self.stop_command(command) + def is_empty(self): + return not self.entity_stack + def get_entity_details(self, command): key = str(command.data) details = self.entity_map.get(key) @@ -59,7 +62,7 @@ def render_entitities(self, root_element, style_node): element_stack = [stack_start] new_element = stack_start - if len(self.entity_stack) == 0: + if self.is_empty(): DOM.append_child(root_element, style_node) else: for entity_details in self.entity_stack: diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py index 28891bf..88436ff 100644 --- a/draftjs_exporter/html.py +++ b/draftjs_exporter/html.py @@ -1,7 +1,7 @@ from __future__ import absolute_import, unicode_literals from draftjs_exporter.command import Command -from draftjs_exporter.composite_decorators import apply_decorators +from draftjs_exporter.composite_decorators import render_decorators from draftjs_exporter.defaults import BLOCK_MAP, STYLE_MAP from draftjs_exporter.dom import DOM from draftjs_exporter.entity_state import EntityState @@ -47,13 +47,11 @@ def render_block(self, block, entity_map): entity_state.apply(command) self.style_state.apply(command) - if entity_state.entity_stack: - decorated_node = DOM.create_text_node(text) + # Entities have priority over decorators. + if entity_state.is_empty(): + decorated_node = render_decorators(self.composite_decorators, text, block.get('type', None)) else: - decorated_node = DOM.create_document_fragment() - - for decorated_child in apply_decorators(self.composite_decorators, text, block.get('type', None)): - DOM.append_child(decorated_node, decorated_child) + decorated_node = DOM.create_text_node(text) styled_node = self.style_state.render_styles(decorated_node) entity_state.render_entitities(element, styled_node) diff --git a/draftjs_exporter/style_state.py b/draftjs_exporter/style_state.py index 2105b97..f89ecc6 100644 --- a/draftjs_exporter/style_state.py +++ b/draftjs_exporter/style_state.py @@ -32,7 +32,7 @@ def apply(self, command): elif command.name == 'stop_inline_style': self.styles.remove(command.data) - def is_unstyled(self): + def is_empty(self): return not self.styles def get_style_tags(self): @@ -56,7 +56,7 @@ def get_style_value(self): return ''.join(sorted(rules)) def render_styles(self, text_node): - if self.is_unstyled(): + if self.is_empty(): node = text_node else: style = self.get_style_value() diff --git a/tests/test_entity_state.py b/tests/test_entity_state.py index c132e76..d450dd3 100644 --- a/tests/test_entity_state.py +++ b/tests/test_entity_state.py @@ -45,6 +45,13 @@ def test_apply_stop_entity(self): self.entity_state.apply(Command('stop_entity', 5, 0)) self.assertEqual(len(self.entity_state.entity_stack), 0) + def test_is_empty_default(self): + self.assertEqual(self.entity_state.is_empty(), True) + + def test_is_empty_styled(self): + self.entity_state.apply(Command('start_entity', 0, 0)) + self.assertEqual(self.entity_state.is_empty(), False) + def test_get_entity_details(self): self.assertEqual(self.entity_state.get_entity_details(Command('start_entity', 0, 0)), { 'data': { diff --git a/tests/test_style_state.py b/tests/test_style_state.py index 37646a0..dfb5db7 100644 --- a/tests/test_style_state.py +++ b/tests/test_style_state.py @@ -31,12 +31,12 @@ def test_apply_stop_inline_style(self): self.style_state.apply(Command('stop_inline_style', 0, 'ITALIC')) self.assertEqual(self.style_state.styles, []) - def test_is_unstyled_default(self): - self.assertEqual(self.style_state.is_unstyled(), True) + def test_is_empty_default(self): + self.assertEqual(self.style_state.is_empty(), True) - def test_is_unstyled_styled(self): + def test_is_empty_styled(self): self.style_state.apply(Command('start_inline_style', 0, 'ITALIC')) - self.assertEqual(self.style_state.is_unstyled(), False) + self.assertEqual(self.style_state.is_empty(), False) def test_get_style_value_empty(self): self.assertEqual(self.style_state.get_style_value(), '') From a212e5a43a346f9975b5c691c52654d77ec77136 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 18 Jan 2017 17:49:47 +0200 Subject: [PATCH 08/13] Bail out of decorators and entities if there are none --- draftjs_exporter/entity_state.py | 14 ++++++-------- draftjs_exporter/html.py | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/draftjs_exporter/entity_state.py b/draftjs_exporter/entity_state.py index 8a8c4dc..34ee8b4 100644 --- a/draftjs_exporter/entity_state.py +++ b/draftjs_exporter/entity_state.py @@ -56,15 +56,15 @@ def stop_command(self, command): self.entity_stack.pop() def render_entitities(self, root_element, style_node): - stack_start = DOM.create_document_fragment() - DOM.append_child(root_element, stack_start) - - element_stack = [stack_start] - new_element = stack_start - if self.is_empty(): DOM.append_child(root_element, style_node) else: + stack_start = DOM.create_document_fragment() + DOM.append_child(root_element, stack_start) + + element_stack = [stack_start] + new_element = stack_start + for entity_details in self.entity_stack: decorator = self.get_entity_decorator(entity_details) props = entity_details.copy() @@ -74,5 +74,3 @@ def render_entitities(self, root_element, style_node): new_element = decorator.render(props) DOM.append_child(element_stack[-1], new_element) element_stack.append(new_element) - - return new_element diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py index 88436ff..55b5740 100644 --- a/draftjs_exporter/html.py +++ b/draftjs_exporter/html.py @@ -48,7 +48,7 @@ def render_block(self, block, entity_map): self.style_state.apply(command) # Entities have priority over decorators. - if entity_state.is_empty(): + if entity_state.is_empty() and len(self.composite_decorators) > 0: decorated_node = render_decorators(self.composite_decorators, text, block.get('type', None)) else: decorated_node = DOM.create_text_node(text) From 442223759bcaf964fc8ed047e01e1298dbbb45d9 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 15 Feb 2017 16:16:49 +0200 Subject: [PATCH 09/13] Change decorators API to use render method --- draftjs_exporter/composite_decorators.py | 9 +- draftjs_exporter/html.py | 2 +- example.py | 51 ++------ tests/test_composite_decorators.py | 143 ++++++----------------- tests/test_output.py | 70 ++++++++++- 5 files changed, 123 insertions(+), 152 deletions(-) diff --git a/draftjs_exporter/composite_decorators.py b/draftjs_exporter/composite_decorators.py index a3fb1d0..c83c046 100644 --- a/draftjs_exporter/composite_decorators.py +++ b/draftjs_exporter/composite_decorators.py @@ -25,10 +25,15 @@ def apply_decorators(decorators, text, block_type): decorations = get_decorations(decorators, text) pointer = 0 - for begin, end, match, deco in decorations: + for begin, end, match, decorator in decorations: if pointer < begin: yield DOM.create_text_node(text[pointer:begin]) - yield deco.replace(match, block_type) + + yield decorator.render({ + 'children': match.group(0), + 'match': match, + 'block_type': block_type, + }) pointer = end if pointer < len(text): diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py index 55b5740..9e8309c 100644 --- a/draftjs_exporter/html.py +++ b/draftjs_exporter/html.py @@ -47,7 +47,7 @@ def render_block(self, block, entity_map): entity_state.apply(command) self.style_state.apply(command) - # Entities have priority over decorators. + # Decorators are not rendered inside entities. if entity_state.is_empty() and len(self.composite_decorators) > 0: decorated_node = render_decorators(self.composite_decorators, text, block.get('type', None)) else: diff --git a/example.py b/example.py index 3f61fe8..d733bc5 100644 --- a/example.py +++ b/example.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals -import cgi import codecs import re @@ -43,51 +42,26 @@ class BR: """ SEARCH_RE = re.compile(r'\n') - def replace(self, match, block_type): - if block_type == BLOCK_TYPES.CODE: - return match.group(0) + def render(self, props): + # Do not process matches inside code blocks. + if props['block_type'] == BLOCK_TYPES.CODE: + return props['children'] return DOM.create_element('br') -class URL: - """ - Replace plain urls with actual hyperlinks. - """ - SEARCH_RE = re.compile(r'(http://|https://|www\.)([a-zA-Z0-9\.\-%/\?&_=\+#:~!,\'\*\^$]+)') - - def __init__(self, new_window=False): - self.new_window = new_window - - def replace(self, match, block_type): - protocol = match.group(1) - href = match.group(2) - href = protocol + href - if block_type == BLOCK_TYPES.CODE: - return href - - text = cgi.escape(href) - if href.startswith("www"): - href = "http://" + href - props = {'href': href} - if self.new_window: - props.update(target="_blank") - - return DOM.create_element('a', props, text) - - class Hashtag: """ - Wrap hash tags in spans with specific class. + Wrap hashtags in spans with a specific class. """ - SEARCH_RE = re.compile(r'#\w+') - def replace(self, match, block_type): - if block_type == BLOCK_TYPES.CODE: - return match.group(0) + def render(self, props): + # Do not process matches inside code blocks. + if props['block_type'] == BLOCK_TYPES.CODE: + return props['children'] - return DOM.create_element('em', {'class': 'hash_tag'}, match.group(0)) + return DOM.create_element('span', {'class': 'hashtag'}, props['children']) config = { @@ -98,7 +72,6 @@ def replace(self, match, block_type): }, 'composite_decorators': [ BR(), - URL(), Hashtag(), ], # Extend/override the default block map. @@ -164,7 +137,7 @@ def replace(self, match, block_type): }, { 'key': '5384u', - 'text': 'Everyone 🍺 Springload applies the best #principles of UX to their work. (https://www.springload.co.nz/work/nz-festival/)', + 'text': 'Everyone 🍺 Springload applies the best #principles of UX to their work.', 'type': 'blockquote', 'depth': 0, 'inlineStyleRanges': [], @@ -247,7 +220,7 @@ def replace(self, match, block_type): 'entityRanges': [ { 'offset': 53, - 'length': 11, + 'length': 12, 'key': 1 } ] diff --git a/tests/test_composite_decorators.py b/tests/test_composite_decorators.py index 97c91cd..1060e87 100644 --- a/tests/test_composite_decorators.py +++ b/tests/test_composite_decorators.py @@ -6,155 +6,80 @@ from draftjs_exporter.constants import BLOCK_TYPES from draftjs_exporter.dom import DOM -from draftjs_exporter.html import HTML -from .test_entities import Link - -class URL: +class Linkify: """ - Replace plain urls with actual hyperlinks. + Wrap plain URLs with link tags. + See http://pythex.org/?regex=(http%3A%2F%2F%7Chttps%3A%2F%2F%7Cwww%5C.)(%5Ba-zA-Z0-9%5C.%5C-%25%2F%5C%3F%26_%3D%5C%2B%23%3A~!%2C%5C%27%5C*%5C%5E%24%5D%2B)&test_string=search%20http%3A%2F%2Fa.us%20or%20https%3A%2F%2Fyahoo.com%20or%20www.google.com%20for%20%23github%20and%20%23facebook&ignorecase=0&multiline=0&dotall=0&verbose=0 + for an example. """ SEARCH_RE = re.compile(r'(http://|https://|www\.)([a-zA-Z0-9\.\-%/\?&_=\+#:~!,\'\*\^$]+)') def __init__(self, new_window=False): self.new_window = new_window - def replace(self, match, block_type): + def render(self, props): + match = props.get('match') + block_type = props.get('block_type') protocol = match.group(1) url = match.group(2) href = protocol + url + if block_type == BLOCK_TYPES.CODE: return href text = cgi.escape(href) - if href.startswith("www"): - href = "http://" + href - props = {'href': href} + if href.startswith('www'): + href = 'http://' + href + + props = { + 'href': href, + } + if self.new_window: - props.update(target="_blank") + props.update(target="_blank", rel="noreferrer noopener") return DOM.create_element('a', props, text) class Hashtag: """ - Wrap hash tags in spans with specific class. + Wrap hashtags in spans with a specific class. """ SEARCH_RE = re.compile(r'#\w+') - def replace(self, match, block_type): + def render(self, props): + # Do not process matches inside code blocks. + if props['block_type'] == BLOCK_TYPES.CODE: + return props['children'] - if block_type == BLOCK_TYPES.CODE: - return match.group(0) - - return DOM.create_element( - 'span', - {'class': 'hash_tag'}, match.group(0) - ) + return DOM.create_element('span', {'class': 'hashtag'}, props['children']) class BR: """ - Wrap hash tags in spans with specific class. + Replace line breaks (\n) with br tags. """ SEARCH_RE = re.compile(r'\n') - def replace(self, match, block_type): - - if block_type == BLOCK_TYPES.CODE: - return match.group(0) + def render(self, props): + # Do not process matches inside code blocks. + if props['block_type'] == BLOCK_TYPES.CODE: + return props['children'] return DOM.create_element('br') +# class TestLinkify(unittest.TestCase): +# def test_init(self): +# self.assertIsInstance(Linkify(), Linkify) -config = { - 'entity_decorators': { - 'LINK': Link() - }, - 'composite_decorators': [ - URL(), - Hashtag() - ], - 'block_map': { - BLOCK_TYPES.UNSTYLED: {'element': 'div'}, - BLOCK_TYPES.CODE: {'element': 'pre'} - }, - 'style_map': { - 'ITALIC': {'element': 'em'}, - 'BOLD': {'element': 'strong'} - } -} +# def test_render(self): +# self.assertEqual(DOM.get_tag_name(DOM.create_element(Linkify, {})), 'fragment') +# self.assertEqual(DOM.get_text_content(DOM.create_element(Linkify, {})), None) class TestCompositeDecorators(unittest.TestCase): def setUp(self): - self.exporter = HTML(config) - self.maxDiff = None - - def test_render_with_entity_and_decorators(self): - """ - The composite decorator should never render text in any entities. - """ - self.assertEqual(self.exporter.render({ - 'entityMap': { - '1': { - 'type': 'LINK', - 'mutability': 'MUTABLE', - 'data': { - 'url': 'http://amazon.us' - } - } - }, - 'blocks': [ - { - 'key': '5s7g9', - 'text': 'search http://a.us or https://yahoo.com or www.google.com for #github and #facebook', - 'type': 'unstyled', - 'depth': 0, - 'inlineStyleRanges': [], - 'entityRanges': [ - { - 'offset': 7, - 'length': 11, - 'key': 1 - } - ], - }, - { - 'key': '34a12', - 'text': '#check www.example.com', - 'type': 'code-block', - 'inlineStyleRanges': [], - }, - ] - }), - '
search http://a.us or ' - 'https://yahoo.com or ' - 'www.google.com for ' - '#github and ' - '#facebook
' - '
#check www.example.com
') - - def test_render_with_multiple_decorators(self): - """ - When multiple decorators match the same part of text, - only the first one should perform the replacement. - """ - self.assertEqual(self.exporter.render({ - 'entityMap': {}, - 'blocks': [ - { - 'key': '5s7g9', - 'text': 'search http://www.google.com#world for the #world', - 'type': 'unstyled', - 'depth': 0, - 'inlineStyleRanges': [], - 'entityRanges': [], - }, - ] - }), - '
search ' - 'http://www.google.com#world for the ' - '#world
') + pass diff --git a/tests/test_output.py b/tests/test_output.py index 85cf357..6ce053d 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -7,7 +7,7 @@ from draftjs_exporter.defaults import BLOCK_MAP from draftjs_exporter.entity_state import EntityException from draftjs_exporter.html import HTML -from tests.test_composite_decorators import BR +from tests.test_composite_decorators import BR, Hashtag, Linkify from tests.test_entities import HR, Link config = { @@ -16,6 +16,8 @@ ENTITY_TYPES.HORIZONTAL_RULE: HR(), }, 'composite_decorators': [ + Linkify(), + Hashtag(), BR(), ], 'block_map': dict(BLOCK_MAP, **{ @@ -931,3 +933,69 @@ def test_render_with_many_line_breaks(self): } ] }), '


some paragraph text
split in half

') + + def test_render_with_entity_and_decorators(self): + """ + The composite decorator should never render text in any entities. + """ + self.assertEqual(self.exporter.render({ + 'entityMap': { + '1': { + 'type': 'LINK', + 'mutability': 'MUTABLE', + 'data': { + 'url': 'http://amazon.us' + } + } + }, + 'blocks': [ + { + 'key': '5s7g9', + 'text': 'search http://a.us or https://yahoo.com or www.google.com for #github and #facebook', + 'type': 'unstyled', + 'depth': 0, + 'inlineStyleRanges': [], + 'entityRanges': [ + { + 'offset': 7, + 'length': 11, + 'key': 1 + } + ], + }, + { + 'key': '34a12', + 'text': '#check www.example.com', + 'type': 'code-block', + 'inlineStyleRanges': [], + }, + ] + }), + '

search http://a.us or ' + 'https://yahoo.com or ' + 'www.google.com for ' + '#github and ' + '#facebook

' + '
#check www.example.com
') + + def test_render_with_multiple_decorators(self): + """ + When multiple decorators match the same part of text, + only the first one should perform the replacement. + """ + self.assertEqual(self.exporter.render({ + 'entityMap': {}, + 'blocks': [ + { + 'key': '5s7g9', + 'text': 'search http://www.google.com#world for the #world', + 'type': 'unstyled', + 'depth': 0, + 'inlineStyleRanges': [], + 'entityRanges': [], + }, + ] + }), + '

search ' + 'http://www.google.com#world for the ' + '#world

') From 3df1496f70312b11479f8b47764925a46269647d Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 15 Feb 2017 16:55:02 +0200 Subject: [PATCH 10/13] Add tests for composite decorators --- tests/test_composite_decorators.py | 90 +++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/tests/test_composite_decorators.py b/tests/test_composite_decorators.py index 1060e87..82d14cd 100644 --- a/tests/test_composite_decorators.py +++ b/tests/test_composite_decorators.py @@ -1,6 +1,6 @@ + from __future__ import absolute_import, unicode_literals -import cgi import re import unittest @@ -16,31 +16,23 @@ class Linkify: """ SEARCH_RE = re.compile(r'(http://|https://|www\.)([a-zA-Z0-9\.\-%/\?&_=\+#:~!,\'\*\^$]+)') - def __init__(self, new_window=False): - self.new_window = new_window - def render(self, props): match = props.get('match') - block_type = props.get('block_type') protocol = match.group(1) url = match.group(2) href = protocol + url - if block_type == BLOCK_TYPES.CODE: + if props['block_type'] == BLOCK_TYPES.CODE: return href - text = cgi.escape(href) - if href.startswith('www'): - href = 'http://' + href - - props = { + link_props = { 'href': href, } - if self.new_window: - props.update(target="_blank", rel="noreferrer noopener") + if href.startswith('www'): + link_props['href'] = 'http://' + href - return DOM.create_element('a', props, text) + return DOM.create_element('a', link_props, href) class Hashtag: @@ -70,13 +62,71 @@ def render(self, props): return DOM.create_element('br') -# class TestLinkify(unittest.TestCase): -# def test_init(self): -# self.assertIsInstance(Linkify(), Linkify) -# def test_render(self): -# self.assertEqual(DOM.get_tag_name(DOM.create_element(Linkify, {})), 'fragment') -# self.assertEqual(DOM.get_text_content(DOM.create_element(Linkify, {})), None) +class TestLinkify(unittest.TestCase): + def test_init(self): + self.assertIsInstance(Linkify(), Linkify) + + def test_render(self): + match = next(Linkify.SEARCH_RE.finditer('test https://www.example.com')) + + self.assertEqual(DOM.render(DOM.create_element(Linkify, { + 'block_type': BLOCK_TYPES.UNSTYLED, + 'match': match, + 'children': match.group(0), + })), 'https://www.example.com') + + def test_render_www(self): + match = next(Linkify.SEARCH_RE.finditer('test www.example.com')) + + self.assertEqual(DOM.render(DOM.create_element(Linkify, { + 'block_type': BLOCK_TYPES.UNSTYLED, + 'match': match, + 'children': match.group(0), + })), 'www.example.com') + + def test_render_code_block(self): + match = next(Linkify.SEARCH_RE.finditer('test https://www.example.com')) + + self.assertEqual(DOM.render(DOM.create_element(Linkify, { + 'block_type': BLOCK_TYPES.CODE, + 'match': match, + 'children': match.group(0), + })), match.group(0)) + + +class TestHashtag(unittest.TestCase): + def test_init(self): + self.assertIsInstance(Hashtag(), Hashtag) + + def test_render(self): + self.assertEqual(DOM.render(DOM.create_element(Hashtag, { + 'block_type': BLOCK_TYPES.UNSTYLED, + 'children': '#hashtagtest', + })), '#hashtagtest') + + def test_render_code_block(self): + self.assertEqual(DOM.render(DOM.create_element(Hashtag, { + 'block_type': BLOCK_TYPES.CODE, + 'children': '#hashtagtest', + })), '#hashtagtest') + + +class TestBR(unittest.TestCase): + def test_init(self): + self.assertIsInstance(BR(), BR) + + def test_render(self): + self.assertEqual(DOM.render(DOM.create_element(BR, { + 'block_type': BLOCK_TYPES.UNSTYLED, + 'children': '\n', + })), '
') + + def test_render_code_block(self): + self.assertEqual(DOM.create_element(BR, { + 'block_type': BLOCK_TYPES.CODE, + 'children': '\n', + }), '\n') class TestCompositeDecorators(unittest.TestCase): From d8657d95e3cc1a0a2f69baab8c27ac1b4fc3369d Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 15 Feb 2017 17:04:17 +0200 Subject: [PATCH 11/13] Rename decorators variable --- draftjs_exporter/composite_decorators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/draftjs_exporter/composite_decorators.py b/draftjs_exporter/composite_decorators.py index c83c046..2927a93 100644 --- a/draftjs_exporter/composite_decorators.py +++ b/draftjs_exporter/composite_decorators.py @@ -8,13 +8,13 @@ def get_decorations(decorators, text): occupied = {} decorations = [] - for deco in decorators: - for match in deco.SEARCH_RE.finditer(text): + for decorator in decorators: + for match in decorator.SEARCH_RE.finditer(text): begin, end = match.span() if not any(occupied.get(i) for i in range(begin, end)): for i in range(begin, end): occupied[i] = 1 - decorations.append((begin, end, match, deco)) + decorations.append((begin, end, match, decorator)) decorations.sort(key=itemgetter(0)) From 72c4e1ee997056aaae281ead3347b008c6ce48f6 Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 15 Feb 2017 17:07:04 +0200 Subject: [PATCH 12/13] Add more decorators tests --- tests/test_composite_decorators.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_composite_decorators.py b/tests/test_composite_decorators.py index 82d14cd..d229197 100644 --- a/tests/test_composite_decorators.py +++ b/tests/test_composite_decorators.py @@ -4,6 +4,7 @@ import re import unittest +from draftjs_exporter.composite_decorators import render_decorators from draftjs_exporter.constants import BLOCK_TYPES from draftjs_exporter.dom import DOM @@ -130,6 +131,14 @@ def test_render_code_block(self): class TestCompositeDecorators(unittest.TestCase): + def test_render_decorators_empty(self): + self.assertEqual(DOM.render(render_decorators([], 'test https://www.example.com#hash #hashtagtest', BLOCK_TYPES.UNSTYLED)), 'test https://www.example.com#hash #hashtagtest') - def setUp(self): - pass + def test_render_decorators_single(self): + self.assertEqual(DOM.render(render_decorators([Linkify()], 'test https://www.example.com#hash #hashtagtest', BLOCK_TYPES.UNSTYLED)), 'test https://www.example.com#hash #hashtagtest') + + def test_render_decorators_conflicting_order_one(self): + self.assertEqual(DOM.render(render_decorators([Linkify(), Hashtag()], 'test https://www.example.com#hash #hashtagtest', BLOCK_TYPES.UNSTYLED)), 'test https://www.example.com#hash #hashtagtest') + + def test_render_decorators_conflicting_order_two(self): + self.assertEqual(DOM.render(render_decorators([Hashtag(), Linkify()], 'test https://www.example.com#hash #hashtagtest', BLOCK_TYPES.UNSTYLED)), 'test https://www.example.com#hash #hashtagtest') From 66ae5ec6bc25dfe30bacd6ac33a661b2eb087d6b Mon Sep 17 00:00:00 2001 From: Thibaud Colas Date: Wed, 15 Feb 2017 17:08:14 +0200 Subject: [PATCH 13/13] Add release notes & succinct docs for decorators feature. Fix #16 --- CHANGELOG.md | 12 +++++++++--- README.rst | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad68842..887e4a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,16 +3,22 @@ > 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). +## [[v0.7.0]](https://github.com/springload/draftjs_exporter/releases/tag/v0.7.0) - 2017-02-15 + +### Added + +- Add support for decorators thanks to @su27 (#16, #17). + ## [[v0.6.2]](https://github.com/springload/draftjs_exporter/releases/tag/v0.6.2) - 2017-01-18 ### Added -- Add profiling tooling thanks to [@su27](https://github.com/su27) [#31](https://github.com/springload/draftjs_exporter/issues/31). -- Add more common entity types in constants (#34) +- Add profiling tooling thanks to @su27 (#31). +- Add more common entity types in constants (#34). ### Fixed -- Stop mutating entity data when rendering entities (#36) +- Stop mutating entity data when rendering entities (#36). ## [[v0.6.1]](https://github.com/springload/draftjs_exporter/releases/tag/v0.6.1) - 2016-12-21 diff --git a/README.rst b/README.rst index f2a901c..e4e09cc 100644 --- a/README.rst +++ b/README.rst @@ -106,6 +106,7 @@ This project adheres to `Semantic Versioning`_, and measures performance and `co * Convert line breaks to ``
`` elements. * Define any attribute in the block map – custom class names for elements. * React-like API to create custom entity decorators. +* React-like API to create composite decorators for text. * Automatic conversion of entity data to HTML attributes (int & boolean to string, ``className`` to ``class``). * Wrapped blocks (``
  • `` elements go inside ``
      ``). * Nested wrapped blocks (multiple list levels, arbitrary type and depth).