Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions mdfluence/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import mdfluence.document
from mdfluence import api
from mdfluence.anchor import rewrite_page_anchors
from mdfluence.console_output import (
console,
error_console,
Expand Down Expand Up @@ -562,9 +561,6 @@ 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
Expand Down Expand Up @@ -698,6 +694,7 @@ def collect_pages_to_upload(args):
remove_text_newlines=args.remove_text_newlines,
enable_relative_links=False,
enable_emoji=not args.disable_emoji,
convert_anchors=args.convert_anchors,
)
)

Expand Down Expand Up @@ -726,6 +723,7 @@ def collect_pages_to_upload(args):
enable_relative_links=args.enable_relative_links,
skip_subtrees_wo_markdown=args.skip_subtrees_wo_markdown,
enable_emoji=not args.disable_emoji,
convert_anchors=args.convert_anchors,
)
else:
try:
Expand All @@ -739,6 +737,7 @@ def collect_pages_to_upload(args):
remove_text_newlines=args.remove_text_newlines,
enable_relative_links=enable_relative_links,
enable_emoji=not args.disable_emoji,
convert_anchors=args.convert_anchors,
)
)
except FileNotFoundError:
Expand Down
92 changes: 46 additions & 46 deletions mdfluence/anchor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Rewrite markdown-style fragment anchors to Confluence-native anchor format.
"""Anchor utilities for rewriting markdown-style fragment anchors to Confluence format.

Confluence generates heading anchors as ``PageTitleStripped-HeadingStripped``
where "stripped" means spaces and hyphens are removed but other characters
Expand All @@ -15,7 +15,6 @@

from __future__ import annotations

import html
import re
from urllib.parse import quote as _url_quote

Expand All @@ -36,28 +35,61 @@ def _heading_to_markdown_anchor(text: str) -> str:
return slug.strip("-")


def _extract_headings(storage_body: str) -> list[str]:
"""Return plain-text heading strings from Confluence storage-format HTML."""
def _extract_headings_from_markdown(markdown_text: str) -> list[str]:
"""Extract heading text from raw markdown lines.

Returns plain heading text strings (without the ``#`` prefix).
Skips headings inside fenced code blocks.
"""
headings: list[str] = []
for m in re.finditer(r"<h[1-6][^>]*>(.*?)</h[1-6]>", storage_body, re.DOTALL):
raw = re.sub(r"<[^>]+>", "", m.group(1))
text = html.unescape(raw).strip()
if text:
in_code_block = False
for line in markdown_text.splitlines():
stripped = line.strip()
if stripped.startswith("```"):
in_code_block = not in_code_block
continue
if in_code_block:
continue
m = re.match(r"^#{1,6}\s+(.+)$", stripped)
if m:
text = m.group(1).strip()
# Remove trailing # characters (alternate heading syntax)
text = re.sub(r"\s+#+\s*$", "", 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.

def _detect_title_from_markdown(
markdown_text: str, frontmatter_title: str | None = None
) -> str:
"""Determine page title from frontmatter or first H1."""
if frontmatter_title:
return frontmatter_title
for line in markdown_text.splitlines():
stripped = line.strip()
m = re.match(r"^#\s+(.+)$", stripped)
if m:
text = m.group(1).strip()
text = re.sub(r"\s+#+\s*$", "", text)
return text
return ""


def build_anchor_map_from_markdown(
markdown_text: str,
page_title: str,
) -> dict[str, str]:
"""Build a mapping *markdown-anchor -> confluence-anchor* from raw markdown.

Pre-scans the markdown source so the map is available before rendering.
Handles duplicate headings with GFM-style suffixes:
first "Setup" ``#setup``, second ``#setup-1``, etc.
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):
for heading in _extract_headings_from_markdown(markdown_text):
md_base = _heading_to_markdown_anchor(heading)
if not md_base:
continue
Expand All @@ -74,38 +106,6 @@ def _build_anchor_map(storage_body: str, page_title: str) -> dict[str, str]:
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
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)
41 changes: 40 additions & 1 deletion mdfluence/confluence_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def __init__(
strip_header=False,
remove_text_newlines=False,
enable_relative_links=False,
anchor_map=None,
):
super().__init__(escape=False)
self.strip_header = strip_header
Expand All @@ -76,13 +77,19 @@ def __init__(
self.relative_links: List[RelativeLink] = list()
self._has_math = False
self._task_id = 0
self._anchor_map: dict = anchor_map or {}
self._heading_counts: dict = {}
self._anchor_map_reverse: dict = (
{v: k for k, v in self._anchor_map.items()} if anchor_map else {}
)

def reinit(self):
self.attachments = list()
self.relative_links = list()
self._has_math = False
self._task_id = 0
self.title = None
self._heading_counts = {}

def heading(self, text, level, **attrs):
if self.title is None and level == 1:
Expand All @@ -91,7 +98,28 @@ def heading(self, text, level, **attrs):
if self.strip_header:
return ""

return super().heading(text, level, **attrs)
heading_html = super().heading(text, level, **attrs)

if self._anchor_map:
# Find the confluence anchor for this heading by tracking heading order
from mdfluence.anchor import _heading_to_markdown_anchor

# Strip HTML tags to get plain text for slug computation
import re

plain_text = re.sub(r"<[^>]+>", "", text).strip()
md_base = _heading_to_markdown_anchor(plain_text)
if md_base:
count = self._heading_counts.get(md_base, 0)
md_anchor = md_base if count == 0 else f"{md_base}-{count}"
self._heading_counts[md_base] = count + 1

if md_anchor in self._anchor_map:
cf_anchor = self._anchor_map[md_anchor]
anchor_macro = self._confluence_anchor(cf_anchor)
heading_html = anchor_macro + heading_html

return heading_html

def structured_macro(self, name):
return ConfluenceTag("structured-macro", attrib={"name": name})
Expand Down Expand Up @@ -127,6 +155,17 @@ def link(self, text, url, title=None):
)
)
url = replacement_link
elif (
self._anchor_map
and not parsed_url.scheme
and not parsed_url.netloc
and not parsed_url.path
and parsed_url.fragment
):
# Local fragment link — rewrite to Confluence anchor
fragment = parsed_url.fragment
if fragment in self._anchor_map:
url = f"#{self._anchor_map[fragment]}"
return super().link(text, url, title)

def text(self, text):
Expand Down
24 changes: 24 additions & 0 deletions mdfluence/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
import yaml
from yaml.parser import ParserError

from mdfluence.anchor import (
_detect_title_from_markdown,
build_anchor_map_from_markdown,
)
from mdfluence.confluence_renderer import ConfluenceRenderer, RelativeLink
from mdfluence.ignored_files import GitRepository
from mdfluence.plugins.alerts import alerts
Expand Down Expand Up @@ -122,6 +126,7 @@ def get_pages_from_directory(
enable_relative_links: bool = False,
skip_subtrees_wo_markdown: bool = False,
enable_emoji: bool = True,
convert_anchors: bool = False,
) -> List[Page]:
"""
Collect a list of markdown files recursively under the file_path directory.
Expand Down Expand Up @@ -237,6 +242,7 @@ def get_pages_from_directory(
remove_text_newlines=remove_text_newlines,
enable_relative_links=enable_relative_links,
enable_emoji=enable_emoji,
convert_anchors=convert_anchors,
)
processed_page.parent_title = parent_page_title
processed_pages.append(processed_page)
Expand All @@ -256,6 +262,7 @@ def get_page_data_from_file_path(
remove_text_newlines: bool = False,
enable_relative_links: bool = False,
enable_emoji: bool = True,
convert_anchors: bool = False,
) -> Page:
if not isinstance(file_path, Path):
file_path = Path(file_path)
Expand All @@ -275,6 +282,7 @@ def get_page_data_from_file_path(
remove_text_newlines=remove_text_newlines,
enable_relative_links=enable_relative_links,
enable_emoji=enable_emoji,
convert_anchors=convert_anchors,
)

if not page.title:
Expand All @@ -291,17 +299,22 @@ def get_page_data_from_lines(
remove_text_newlines: bool = False,
enable_relative_links: bool = False,
enable_emoji: bool = True,
convert_anchors: bool = False,
) -> Page:
frontmatter = get_document_frontmatter(markdown_lines)
if "frontmatter_end_line" in frontmatter:
markdown_lines = markdown_lines[frontmatter["frontmatter_end_line"] :]

frontmatter_title = frontmatter.get("title")

page = parse_page(
markdown_lines,
strip_header=strip_header,
remove_text_newlines=remove_text_newlines,
enable_relative_links=enable_relative_links,
enable_emoji=enable_emoji,
convert_anchors=convert_anchors,
frontmatter_title=frontmatter_title,
)

if "title" in frontmatter:
Expand All @@ -323,11 +336,22 @@ def parse_page(
remove_text_newlines: bool = False,
enable_relative_links: bool = False,
enable_emoji: bool = True,
convert_anchors: bool = False,
frontmatter_title: str | None = None,
) -> Page:
markdown_text = "".join(markdown_lines)

# Pre-scan for anchor map if anchor conversion is enabled
anchor_map = None
if convert_anchors:
page_title = _detect_title_from_markdown(markdown_text, frontmatter_title)
anchor_map = build_anchor_map_from_markdown(markdown_text, page_title)

renderer = ConfluenceRenderer(
strip_header=strip_header,
remove_text_newlines=remove_text_newlines,
enable_relative_links=enable_relative_links,
anchor_map=anchor_map,
)
confluence_mistune = mistune.Markdown(renderer=renderer)
for plugin in [
Expand Down
Loading
Loading