diff --git a/md2cf/__main__.py b/md2cf/__main__.py index 7db41fb..f4453ad 100644 --- a/md2cf/__main__.py +++ b/md2cf/__main__.py @@ -16,6 +16,7 @@ import md2cf.document from md2cf import api +from md2cf.anchor import rewrite_page_anchors from md2cf.console_output import ( console, error_console, @@ -227,6 +228,15 @@ def get_parser(): "existing and uploaded file, leave the link as-is instead of exiting.", ) + anchor_group = parser.add_argument_group("anchor arguments") + anchor_group.add_argument( + "--convert-anchors", + action=argparse.BooleanOptionalAction, + default=True, + help="rewrite markdown-style fragment anchors to Confluence-native format. " + "Enabled by default. Use --no-convert-anchors to disable.", + ) + parser.add_argument( "--dry-run", action="store_true", @@ -539,6 +549,9 @@ def pre_process_page(page, args, postface_markup, preface_markup, space_info): if postface_markup: page.body = page.body + postface_markup + if args.convert_anchors: + page.body = rewrite_page_anchors(page.body, page.title) + def validate_relative_links(pages_to_upload, path_to_page): invalid_links = False diff --git a/md2cf/anchor.py b/md2cf/anchor.py new file mode 100644 index 0000000..4072fd3 --- /dev/null +++ b/md2cf/anchor.py @@ -0,0 +1,111 @@ +"""Rewrite markdown-style fragment anchors to Confluence-native anchor format. + +Confluence generates heading anchors as ``PageTitleStripped-HeadingStripped`` +where "stripped" means spaces and hyphens are removed but other characters +(like parentheses) are kept, with original casing preserved. + +Example +------- +Page title : "SSH Reverse Tunnel Setup Guide - Embedded Hardware to AWS EC2" +Heading : "The Concept" + +Markdown anchor : ``#the-concept`` +Confluence anchor : ``#SSHReverseTunnelSetupGuideEmbeddedHardwaretoAWSEC2-TheConcept`` +""" + +from __future__ import annotations + +import html +import re +from urllib.parse import quote as _url_quote + + +def _strip_for_anchor(text: str) -> str: + """Remove spaces and hyphens/dashes. + + Confluence keeps other chars like parentheses. + """ + return re.sub(r"[\s\-]", "", text) + + +def _heading_to_markdown_anchor(text: str) -> str: + """Convert a heading string to a GitHub-Flavored-Markdown anchor slug.""" + slug = text.lower() + slug = re.sub(r"[^\w\s-]", "", slug) + slug = re.sub(r"\s+", "-", slug) + return slug.strip("-") + + +def _extract_headings(storage_body: str) -> list[str]: + """Return plain-text heading strings from Confluence storage-format HTML.""" + headings: list[str] = [] + for m in re.finditer(r"]*>(.*?)", storage_body, re.DOTALL): + raw = re.sub(r"<[^>]+>", "", m.group(1)) + text = html.unescape(raw).strip() + if text: + headings.append(text) + return headings + + +def _build_anchor_map(storage_body: str, page_title: str) -> dict[str, str]: + """Build a mapping *markdown-anchor → confluence-anchor* for every heading. + + Handles duplicate headings with GFM-style suffixes: + first "Setup" → ``#setup``, second → ``#setup-1``, etc. + """ + title_part = _strip_for_anchor(page_title) + anchor_map: dict[str, str] = {} + seen_counts: dict[str, int] = {} + + for heading in _extract_headings(storage_body): + md_base = _heading_to_markdown_anchor(heading) + if not md_base: + continue + + count = seen_counts.get(md_base, 0) + md_anchor = md_base if count == 0 else f"{md_base}-{count}" + seen_counts[md_base] = count + 1 + + cf_base = f"{title_part}-{_strip_for_anchor(heading)}" + cf_anchor = cf_base if count == 0 else f"{cf_base}-{count}" + + cf_anchor = _url_quote(cf_anchor, safe="-") + + if cf_anchor and not cf_anchor[0].isalpha(): + cf_anchor = f"id-{cf_anchor}" + + if md_anchor != cf_anchor: + anchor_map[md_anchor] = cf_anchor + + return anchor_map + + +def _rewrite_anchors(storage_body: str, anchor_map: dict[str, str]) -> str: + """Replace markdown-style anchors with Confluence-style ones.""" + + def _replace_href(m: re.Match[str]) -> str: + anchor = m.group(1) + if anchor in anchor_map: + return f'href="#{anchor_map[anchor]}"' + return m.group(0) + + def _replace_ac_anchor(m: re.Match[str]) -> str: + anchor = m.group(1) + if anchor in anchor_map: + return f'ac:anchor="{anchor_map[anchor]}"' + return m.group(0) + + storage_body = re.sub(r'href="#([^"]+)"', _replace_href, storage_body) + storage_body = re.sub(r'ac:anchor="([^"]+)"', _replace_ac_anchor, storage_body) + return storage_body + + +def rewrite_page_anchors(body: str, page_title: str) -> str: + """Rewrite markdown-style fragment anchors in *body* to Confluence-native format. + + Returns *body* unchanged if no anchors need rewriting. + """ + anchor_map = _build_anchor_map(body, page_title) + if not anchor_map: + return body + return _rewrite_anchors(body, anchor_map) diff --git a/test_package/unit/test_anchor.py b/test_package/unit/test_anchor.py new file mode 100644 index 0000000..73bf9c9 --- /dev/null +++ b/test_package/unit/test_anchor.py @@ -0,0 +1,197 @@ +from md2cf.anchor import ( + _build_anchor_map, + _extract_headings, + _heading_to_markdown_anchor, + _rewrite_anchors, + _strip_for_anchor, + rewrite_page_anchors, +) + + +class TestStripForAnchor: + def test_removes_spaces(self): + assert _strip_for_anchor("The Concept") == "TheConcept" + + def test_removes_hyphens(self): + assert ( + _strip_for_anchor("SSH Reverse Tunnel - Guide") == "SSHReverseTunnelGuide" + ) + + def test_keeps_parentheses(self): + assert ( + _strip_for_anchor("Disable Service (if needed)") + == "DisableService(ifneeded)" + ) + + def test_empty_string(self): + assert _strip_for_anchor("") == "" + + +class TestHeadingToMarkdownAnchor: + def test_basic_heading(self): + assert _heading_to_markdown_anchor("The Concept") == "the-concept" + + def test_strips_special_chars(self): + assert _heading_to_markdown_anchor("What's Next?") == "whats-next" + + def test_multiple_spaces(self): + assert _heading_to_markdown_anchor("A B C") == "a-b-c" + + def test_preserves_hyphens(self): + assert _heading_to_markdown_anchor("pre-existing") == "pre-existing" + + def test_empty_string(self): + assert _heading_to_markdown_anchor("") == "" + + def test_with_parentheses(self): + assert _heading_to_markdown_anchor("Setup (Optional)") == "setup-optional" + + +class TestExtractHeadings: + def test_basic_headings(self): + body = "

Introduction

text

Details

" + assert _extract_headings(body) == ["Introduction", "Details"] + + def test_strips_nested_tags(self): + body = "

Bold Heading

" + assert _extract_headings(body) == ["Bold Heading"] + + def test_ignores_empty_headings(self): + body = "

Real

" + assert _extract_headings(body) == ["Real"] + + def test_unescapes_html_entities(self): + body = "

A & B

" + assert _extract_headings(body) == ["A & B"] + + def test_no_headings(self): + assert _extract_headings("

just a paragraph

") == [] + + def test_heading_with_attributes(self): + body = '

Heading

' + assert _extract_headings(body) == ["Heading"] + + +class TestBuildAnchorMap: + def test_basic_mapping(self): + body = "

The Concept

" + result = _build_anchor_map(body, "My Guide") + assert result == {"the-concept": "MyGuide-TheConcept"} + + def test_duplicate_headings(self): + body = "

Setup

Setup

Setup

" + result = _build_anchor_map(body, "Page") + assert result == { + "setup": "Page-Setup", + "setup-1": "Page-Setup-1", + "setup-2": "Page-Setup-2", + } + + def test_url_encodes_special_chars(self): + body = "

Disable Service (if needed)

" + result = _build_anchor_map(body, "Guide") + assert result == { + "disable-service-if-needed": "Guide-DisableService%28ifneeded%29", + } + + def test_id_prefix_for_non_alpha_start(self): + body = "

3rd Party Libraries

" + result = _build_anchor_map(body, "") + # Title is empty so cf_anchor starts with "-" which is non-alpha + assert "3rd-party-libraries" in result + assert result["3rd-party-libraries"].startswith("id-") + + def test_skips_when_md_equals_cf(self): + # Unlikely in practice but should not appear in map + body = "

a

" + result = _build_anchor_map(body, "") + # md_anchor = "a", cf_anchor = "-a" (starts with -), so id prefix is added + # They won't be equal in this case, but test the concept: + # If they were equal, they'd be skipped + for md, cf in result.items(): + assert md != cf + + def test_empty_body(self): + assert _build_anchor_map("", "Title") == {} + + def test_long_title_with_hyphens(self): + body = "

The Concept

" + title = "SSH Reverse Tunnel Setup Guide" " - Embedded Hardware to AWS EC2" + result = _build_anchor_map(body, title) + expected_anchor = ( + "SSHReverseTunnelSetupGuideEmbeddedHardwaretoAWSEC2" "-TheConcept" + ) + assert result == {"the-concept": expected_anchor} + + +class TestRewriteAnchors: + def test_rewrites_href(self): + body = 'link' + anchor_map = {"the-concept": "MyGuide-TheConcept"} + assert ( + _rewrite_anchors(body, anchor_map) + == 'link' + ) + + def test_rewrites_ac_anchor(self): + body = ( + '' + "text" + ) + anchor_map = {"the-concept": "MyGuide-TheConcept"} + result = _rewrite_anchors(body, anchor_map) + assert 'ac:anchor="MyGuide-TheConcept"' in result + + def test_leaves_unknown_anchors(self): + body = 'link' + anchor_map = {"the-concept": "MyGuide-TheConcept"} + assert _rewrite_anchors(body, anchor_map) == body + + def test_multiple_anchors(self): + body = '12' + anchor_map = {"one": "Page-One", "two": "Page-Two"} + result = _rewrite_anchors(body, anchor_map) + assert 'href="#Page-One"' in result + assert 'href="#Page-Two"' in result + + +class TestRewritePageAnchors: + def test_end_to_end(self): + body = ( + "

Introduction

" + '

See the concept.

' + "

The Concept

" + "

Details here.

" + '

Back to intro.

' + ) + result = rewrite_page_anchors(body, "My Guide") + assert 'href="#MyGuide-TheConcept"' in result + assert 'href="#MyGuide-Introduction"' in result + + def test_no_headings_returns_unchanged(self): + body = '

No headings here.

' + assert rewrite_page_anchors(body, "Title") == body + + def test_no_fragment_links_returns_unchanged(self): + body = "

Heading

No links here.

" + # There are headings but no fragment links to rewrite, body is unchanged + result = rewrite_page_anchors(body, "Title") + assert result == body + + def test_non_matching_fragment_left_untouched(self): + body = "

Real Heading

" '

link

' + result = rewrite_page_anchors(body, "Page") + # The #nonexistent anchor doesn't match any heading, left as-is + assert 'href="#nonexistent"' in result + # But the heading-matching anchors would be rewritten if referenced + assert ( + 'href="#Page-RealHeading"' not in result + ) # not referenced, so not in body + + def test_with_prefix_in_title(self): + body = "

Setup

" 'go' + result = rewrite_page_anchors(body, "PREFIX - My Page") + assert 'href="#PREFIXMyPage-Setup"' in result + + def test_empty_body(self): + assert rewrite_page_anchors("", "Title") == ""