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") == ""