diff --git a/sanity_html/dataclasses.py b/sanity_html/dataclasses.py
index 0ebe8f5..24cf457 100644
--- a/sanity_html/dataclasses.py
+++ b/sanity_html/dataclasses.py
@@ -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
@@ -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:
@@ -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]:
diff --git a/sanity_html/renderer.py b/sanity_html/renderer.py
index 7c4c498..bfdfd8e 100644
--- a/sanity_html/renderer.py
+++ b/sanity_html/renderer.py
@@ -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
@@ -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]
@@ -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):
@@ -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()
@@ -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):
@@ -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 ''
@@ -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'
{self._render_block(Block(**child), True)}'
+ result += tail
+ return result
- list_item = node.pop('listItem') # popping this attribute lets us call render_node for non-list handling
- node_inner_html = '' + ''.join(list(self._render_node(node, list_item=True))) + ''
+ 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:
diff --git a/sanity_html/utils.py b/sanity_html/utils.py
index d3c6d5e..1c75293 100644
--- a/sanity_html/utils.py
+++ b/sanity_html/utils.py
@@ -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.
@@ -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]:
diff --git a/tests/test_upstream_suite.py b/tests/test_upstream_suite.py
index 2a33893..de16467 100644
--- a/tests/test_upstream_suite.py
+++ b/tests/test_upstream_suite.py
@@ -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}&w={width}&h={height}'
+
+ image = f''
+ if context:
+ return image
+ return f''
def get_fixture(rel_path) -> dict:
@@ -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
@@ -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
@@ -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
@@ -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('