Skip to content

Commit

Permalink
Merge pull request #7 from otovo/klette/custom-serializers
Browse files Browse the repository at this point in the history
Initial stab at custom serializer and marker def support
  • Loading branch information
klette authored Apr 17, 2021
2 parents b7fc2b8 + 7bc5601 commit eb71383
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 47 deletions.
8 changes: 5 additions & 3 deletions sanity_html/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, cast

from sanity_html.utils import get_marker_definitions
from sanity_html.utils import get_default_marker_definitions

if TYPE_CHECKING:
from typing import Literal, Optional, Tuple, Type, Union
Expand Down Expand Up @@ -44,7 +44,7 @@ class Block:
listItem: Optional[Literal['bullet', 'number', 'square']] = None
children: list[dict] = field(default_factory=list)
markDefs: list[dict] = field(default_factory=list)
marker_definitions: dict[str, Type[MarkerDefinition]] = field(init=False)
marker_definitions: dict[str, Type[MarkerDefinition]] = field(default_factory=dict)
marker_frequencies: dict[str, int] = field(init=False)

def __post_init__(self) -> None:
Expand All @@ -54,7 +54,9 @@ def __post_init__(self) -> None:
To make handling of span `marks` simpler, we define marker_definitions as a dict, from which
we can directly look up both annotation marks or decorator marks.
"""
self.marker_definitions = get_marker_definitions(self.markDefs)
marker_definitions = get_default_marker_definitions(self.markDefs)
marker_definitions.update(self.marker_definitions)
self.marker_definitions = marker_definitions
self.marker_frequencies = self._compute_marker_frequencies()

def _compute_marker_frequencies(self) -> dict[str, int]:
Expand Down
127 changes: 96 additions & 31 deletions sanity_html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from sanity_html.utils import get_list_tags, is_block, is_list, is_span

if TYPE_CHECKING:
from typing import Dict, List, Optional, Union
from typing import Callable, Dict, List, Optional, Type, Union

from sanity_html.marker_definitions import MarkerDefinition


# TODO: Let user pass custom code block definitions/plugins
Expand All @@ -19,8 +21,15 @@
class SanityBlockRenderer:
"""HTML renderer for Sanity block content."""

def __init__(self, blocks: Union[list[dict], dict]) -> None:
def __init__(
self,
blocks: Union[list[dict], dict],
custom_marker_definitions: dict[str, Type[MarkerDefinition]] = None,
custom_serializers: dict[str, Callable[[dict, Optional[Block], bool], str]] = None,
) -> None:
self._wrapper_element: Optional[str] = None
self._custom_marker_definitions = custom_marker_definitions or {}
self._custom_serializers = custom_serializers or {}

if isinstance(blocks, dict):
self._blocks = [blocks]
Expand All @@ -38,7 +47,8 @@ def render(self) -> str:
for node in self._blocks:

if list_nodes and not is_list(node):
result += self._render_list(list_nodes)
tree = self._normalize_list_tree(list_nodes, Block(**node))
result += ''.join([self._render_node(n, Block(**node), list_item=True) for n in tree])
list_nodes = [] # reset list_nodes

if is_list(node):
Expand All @@ -48,7 +58,11 @@ def render(self) -> str:
result += self._render_node(node) # render non-list nodes immediately

if list_nodes:
result += self._render_list(list_nodes)
tree = self._normalize_list_tree(list_nodes, Block(**node))
result += ''.join(
self._render_node(n, Block(**node), list_item=True) for n in tree
)


result = result.strip()

Expand All @@ -64,8 +78,11 @@ def _render_node(self, node: dict, context: Optional[Block] = None, list_item: b
:param context: Optional context. Spans are passed with a Block instance as context for mark lookups.
:param list_item: Whether we are handling a list upstream (impacts block handling).
"""
if is_block(node):
block = Block(**node)
if is_list(node):
block = Block(**node, marker_definitions=self._custom_marker_definitions)
return self._render_list(block, context)
elif is_block(node):
block = Block(**node, marker_definitions=self._custom_marker_definitions)
return self._render_block(block, list_item=list_item)

elif is_span(node):
Expand All @@ -78,7 +95,8 @@ def _render_node(self, node: dict, context: Optional[Block] = None, list_item: b

assert context # this should be a cast
return self._render_span(span, block=context) # context is span's outer block

elif custom_serializer := self._custom_serializers.get(node.get('_type', '')):
return custom_serializer(node, context, list_item)
else:
print('Unexpected code path 👺') # noqa: T001 # TODO: Remove after thorough testing
return ''
Expand Down Expand Up @@ -120,40 +138,87 @@ def _render_span(self, span: Span, block: Block) -> str:

return result

def _render_list(self, nodes: list) -> str:
result, tag_dict = '', {}
for index, node in enumerate(nodes):

current_level = node['level'] # 1
prev_level = nodes[index - 1]['level'] if index > 0 else 0 # default triggers first condition below
def _render_list(self, node: Block, context: Optional[Block]) -> str:
assert node.listItem
head, tail = get_list_tags(node.listItem)
result = head
for child in node.children:
result += f'<li>{self._render_block(Block(**child), True)}</li>'
result += tail
return result

list_item = node.pop('listItem') # popping this attribute lets us call render_node for non-list handling
node_inner_html = '<li>' + ''.join(list(self._render_node(node, list_item=True))) + '</li>'
def _normalize_list_tree(self, nodes: list, block: Block) -> list[dict]:
tree = []

if current_level > prev_level:
list_tags = get_list_tags(list_item)
result += list_tags[0]
result += node_inner_html
tag_dict[current_level] = list_tags[1]
current_list = None
for node in nodes:
if not is_block(node):
tree.append(node)
current_list = None
continue

elif current_level == prev_level:
result += node_inner_html
if current_list is None:
current_list = self._list_from_block(node)
tree.append(current_list)
continue

elif current_level < prev_level:
result += node_inner_html
result += tag_dict.pop(prev_level)
if node.get('level') == current_list['level'] and node.get('listItem') == current_list['listItem']:
current_list['children'].append(node)
continue

else:
print('Unexpected code path 🕵🏻‍') # noqa: T001 # TODO: Remove or alter when done testing
if node.get('level') > current_list['level']:
new_list = self._list_from_block(node)
current_list['children'][-1]['children'].append(new_list)
current_list = new_list
continue

# there should be one or more tag in the dict for us to close off
for value in tag_dict.values():
result += value
if node.get('level') < current_list['level']:
parent = self._find_list(tree[-1], level=node.get('level'), list_item=node.get('listItem'))
if parent:
current_list = parent
current_list['children'].append(node)
continue
current_list = self._list_from_block(node)
tree.append(current_list)
continue

return result
if node.get('listItem') != current_list['listItem']:
match = self._find_list(tree[-1], level=node.get('level'))
if match and match['listItem'] == node.get('listItem'):
current_list = match
current_list.children.append(node)
continue
current_list = self._list_from_block(node)
tree.append(current_list)
continue
# TODO: Warn
tree.append(node)

return tree

def _find_list(self, root_node: dict, level: int, list_item: Optional[str] = None) -> Optional[dict]:
filter_on_type = isinstance(list_item, str)
if (
root_node.get('_type') == 'list'
and root_node.get('level') == level
and (filter_on_type and root_node.get('listItem') == list_item)
):
return root_node

children = root_node.get('children')
if children:
return self._find_list(children[-1], level, list_item)

return None

def _list_from_block(self, block: dict) -> dict:
return {
'_type': 'list',
'_key': f'${block["_key"]}-parent',
'level': block.get('level'),
'listItem': block['listItem'],
'children': [block],
}


def render(blocks: List[Dict]) -> str:
Expand Down
4 changes: 2 additions & 2 deletions sanity_html/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sanity_html.marker_definitions import MarkerDefinition


def get_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[MarkerDefinition]]:
def get_default_marker_definitions(mark_defs: list[dict]) -> dict[str, Type[MarkerDefinition]]:
"""
Convert JSON definitions to a map of marker definition renderers.
Expand Down Expand Up @@ -38,7 +38,7 @@ def is_span(node: dict) -> bool:

def is_block(node: dict) -> bool:
"""Check whether a node is a block node."""
return node.get('_type') == 'block' and 'listItem' not in node
return node.get('_type') == 'block'


def get_list_tags(list_item: str) -> tuple[str, str]:
Expand Down
71 changes: 60 additions & 11 deletions tests/test_upstream_suite.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
import json
import re
from pathlib import Path
from typing import Optional

import pytest

from sanity_html import render
from sanity_html.dataclasses import Block
from sanity_html.marker_definitions import LinkMarkerDefinition, MarkerDefinition
from sanity_html.renderer import SanityBlockRenderer


def fake_image_serializer(node: dict, context: Optional[Block], list_item: bool):
assert node['_type'] == 'image'
if 'url' in node['asset']:
image_url = node['asset']['url']
else:
project_id = '3do82whm'
dataset = 'production'
asset_ref: str = node['asset']['_ref']
image_path = asset_ref[6:].replace('-jpg', '.jpg').replace('-png', '.png')
image_url = f'https://cdn.sanity.io/images/{project_id}/{dataset}/{image_path}'

if 'crop' in node and 'hotspot' in node:
crop = node['crop']
hotspot = node['hotspot']
size_match = re.match(r'.*-(\d+)x(\d+)\..*', image_url)
if size_match:
orig_width, orig_height = [int(x) for x in size_match.groups()]
rect_x1 = round((orig_width * hotspot['x']) - ((orig_width * hotspot['width']) / 2))
rect_y1 = round((orig_height * hotspot['y']) - ((orig_height * hotspot['height']) / 2))
rect_x2 = round(orig_width - (orig_width * crop['left']) - (orig_width * crop['right']))
rect_y2 = round(orig_height - (orig_height * crop['top']) - (orig_height * crop['bottom']))
# These are passed as "imageOptions" upstream.
# It's up the the implementor of the serializer to fix this.
# We might provide one for images that does something like this, but for now
# let's just make the test suite pass
width = 320
height = 240

image_url += f'?rect={rect_x1},{rect_y1},{rect_x2},{rect_y2}&amp;w={width}&amp;h={height}'

image = f'<img src="{image_url}"/>'
if context:
return image
return f'<figure>{image}</figure>'


def get_fixture(rel_path) -> dict:
Expand Down Expand Up @@ -102,21 +143,21 @@ def test_011_basic_numbered_list():
assert output == expected_output


@pytest.mark.unsupported
def test_012_image_support():
fixture_data = get_fixture('fixtures/upstream/012-image-support.json')
input_blocks = fixture_data['input']
expected_output = fixture_data['output']
output = render(input_blocks)
sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
output = sbr.render()
assert output == expected_output


@pytest.mark.unsupported
def test_013_materialized_image_support():
fixture_data = get_fixture('fixtures/upstream/013-materialized-image-support.json')
input_blocks = fixture_data['input']
expected_output = fixture_data['output']
output = render(input_blocks)
sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
output = sbr.render()
assert output == expected_output


Expand Down Expand Up @@ -189,7 +230,8 @@ def test_022_inline_node():
fixture_data = get_fixture('fixtures/upstream/022-inline-nodes.json')
input_blocks = fixture_data['input']
expected_output = fixture_data['output']
output = render(input_blocks)
sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
output = sbr.render()
assert output == expected_output


Expand All @@ -201,21 +243,21 @@ def test_023_hard_break():
assert output == expected_output


@pytest.mark.unsupported
def test_024_inline_image():
fixture_data = get_fixture('fixtures/upstream/024-inline-images.json')
input_blocks = fixture_data['input']
expected_output = fixture_data['output']
output = render(input_blocks)
sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
output = sbr.render()
assert output == expected_output


@pytest.mark.unsupported
def test_025_image_with_hotspot():
fixture_data = get_fixture('fixtures/upstream/025-image-with-hotspot.json')
input_blocks = fixture_data['input']
expected_output = fixture_data['output']
output = render(input_blocks)
sbr = SanityBlockRenderer(input_blocks, custom_serializers={'image': fake_image_serializer})
output = sbr.render()
assert output == expected_output


Expand Down Expand Up @@ -262,12 +304,19 @@ def test_052_custom_mark():
assert output == expected_output


@pytest.mark.unsupported
def test_053_override_default_mark():
fixture_data = get_fixture('fixtures/upstream/053-override-default-marks.json')
input_blocks = fixture_data['input']
expected_output = fixture_data['output']
output = render(input_blocks)

class CustomLinkMark(LinkMarkerDefinition):
@classmethod
def render_prefix(cls, span, marker, context) -> str:
result = super().render_prefix(span, marker, context)
return result.replace('<a href', '<a class=\"mahlink\" href')

sbr = SanityBlockRenderer(input_blocks, custom_marker_definitions={'mark1': CustomLinkMark})
output = sbr.render()
assert output == expected_output


Expand Down

0 comments on commit eb71383

Please sign in to comment.