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'
    {image}
    ' 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('