diff --git a/n2y/plugins/internallinks.py b/n2y/plugins/internallinks.py index b04ed35..1253a3a 100644 --- a/n2y/plugins/internallinks.py +++ b/n2y/plugins/internallinks.py @@ -81,6 +81,14 @@ def to_pandoc(self): ) # Fallback to default behavior for TextRichText conversion return super().to_pandoc() + if getattr(target_block, "rich_text", None) is None: + # Links can target blocks with no text to anchor to (e.g. images); + # there is no header id to point at, so leave the link unchanged. + self.client.logger.warning( + f"Internal link target block {target_id} has no text; " + "leaving link unresolved" + ) + return super().to_pandoc() header_id = header_id_from_text(target_block.rich_text.to_plain_text()) self.href = f"#{header_id}" return super().to_pandoc() diff --git a/n2y/utils.py b/n2y/utils.py index 99ca3cf..29bc120 100644 --- a/n2y/utils.py +++ b/n2y/utils.py @@ -230,6 +230,11 @@ def header_id_from_text(header_text, existing_ids=None): See https://pandoc.org/MANUAL.html#extension-auto_identifiers """ + # Markdown parsers (pandoc, kramdown) strip surrounding whitespace from + # heading text before generating ids; Notion rich text can carry trailing + # spaces, which would otherwise become trailing hyphens that don't match + # the ids the downstream renderer produces. + header_text = header_text.strip() have_struck_letter = False new_header_text = "" diff --git a/tests/test_plugin_internallinks.py b/tests/test_plugin_internallinks.py index 1829d38..7a2fce4 100644 --- a/tests/test_plugin_internallinks.py +++ b/tests/test_plugin_internallinks.py @@ -1,6 +1,6 @@ from unittest.mock import Mock, patch -from n2y.blocks import ChildPageBlock, HeadingOneBlock, ParagraphBlock +from n2y.blocks import ChildPageBlock, DividerBlock, HeadingOneBlock, ParagraphBlock from n2y.notion import Client from n2y.notion_mocks import ( mock_block, @@ -110,3 +110,51 @@ def test_internal_link_to_pandoc(): markdown = pandoc_ast_to_markdown(page.to_pandoc(), Mock()) assert f"# {header_text}" in markdown assert f"[{link_text}](#{header_id_from_text(header_text)})" in markdown + + +@patch("n2y.notion.Client.wrap_notion_user") +def mock_page_with_link_to_divider(wrap_notion_user, link_text: str = "see below"): + client = Client("", plugins=["n2y.plugins.internallinks"]) + wrap_notion_user.return_value = User(client, mock_user()) + + page = Page(client, notion_data=mock_page()) + page_block = ChildPageBlock( + client=client, + notion_data=mock_block("child_page", {"title": "Mock Page"}), + page=page, + get_children=False, + ) + page._block = page_block + + divider_block = DividerBlock( + client=client, + notion_data=mock_block("divider", {}), + page=page, + get_children=False, + ) + + href = ( + f"/{page.notion_id.replace('-', '')}" + f"#{divider_block.notion_id.replace('-', '')}" + ) + paragraph_with_link_block = ParagraphBlock( + client=client, + notion_data=mock_block( + "paragraph", + {"rich_text": [mock_rich_text(text=link_text, href=href)]}, + ), + page=page, + get_children=False, + ) + + page.block.children = [divider_block, paragraph_with_link_block] + return page, href + + +def test_internal_link_to_textless_block_left_unresolved(): + # Links can target blocks with no rich_text (images, dividers); the plugin + # must not crash and should leave the link unchanged. + link_text = "see below" + page, href = mock_page_with_link_to_divider(link_text=link_text) + markdown = pandoc_ast_to_markdown(page.to_pandoc(), Mock()) + assert f"[{link_text}]({href})" in markdown