Skip to content
Open
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
21 changes: 18 additions & 3 deletions confluence-mdx/bin/mdx_to_storage/inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
_BADGE_INLINE_RE = re.compile(
r'<Badge\s+color="([^"]+)">(.*?)</Badge>', flags=re.DOTALL
)
_BARE_XML_AMPERSAND_RE = re.compile(
r"&(?!(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);)"
)
_BADGE_COLOR_MAP = {
"green": "Green",
"blue": "Blue",
Expand All @@ -27,6 +30,11 @@
}


def escape_bare_xml_ampersands(value: str) -> str:
"""inline markup은 유지하면서 XML entity가 아닌 ampersand만 escape합니다."""
return _BARE_XML_AMPERSAND_RE.sub("&amp;", value)


def _replace_badge(match: re.Match[str]) -> str:
color = match.group(1).strip().lower()
text = match.group(2).strip()
Expand Down Expand Up @@ -110,11 +118,18 @@ def _replace_link(match: re.Match[str]) -> str:
if not content_title:
return f'<a href="{href}">{link_text}</a>'

anchor_attr = f' ac:anchor="{anchor}"' if anchor else ""
anchor_attr = (
f' ac:anchor="{html.escape(anchor, quote=True)}"'
if anchor
else ""
)
return (
f"<ac:link{anchor_attr}>"
f'<ri:page ri:content-title="{content_title}"></ri:page>'
f"<ac:link-body>{link_text}</ac:link-body>"
f'<ri:page ri:content-title="'
f'{html.escape(content_title, quote=True)}"></ri:page>'
f"<ac:link-body>"
f"{escape_bare_xml_ampersands(link_text)}"
f"</ac:link-body>"
f"</ac:link>"
)

Expand Down
129 changes: 101 additions & 28 deletions confluence-mdx/bin/mdx_to_storage/link_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ class PageEntry:
path: list[str] = field(default_factory=list)


@dataclass(frozen=True)
class LinkResolution:
"""internal link resolution 결과와 fail-closed 상태."""

status: str
href: str
content_title: Optional[str] = None
anchor: Optional[str] = None
candidate_page_ids: tuple[str, ...] = ()


def load_pages_yaml(yaml_path: Path) -> list[PageEntry]:
if not yaml_path.exists():
return []
Expand Down Expand Up @@ -73,56 +84,81 @@ def __init__(self, pages: Optional[list[PageEntry] | Path] = None) -> None:

self._by_id: dict[str, PageEntry] = {}
self._current_page: PageEntry | None = None
self._path_to_title: dict[str, str] = {}
self._titles: set[str] = set()
self._path_to_entries: dict[str, list[PageEntry]] = {}
self._title_to_entries: dict[str, list[PageEntry]] = {}
self._load_pages(pages)

def has_pages(self) -> bool:
return bool(self._path_to_title)
return bool(self._path_to_entries)

def set_current_page(self, page_id: str) -> None:
self._current_page = self._by_id.get(str(page_id))

def resolve(self, href: str, link_text: str = "") -> tuple[Optional[str], Optional[str]]:
"""Resolve href to (content_title, anchor) or (None, None)."""
resolution = self.resolve_with_evidence(href, link_text=link_text)
if resolution.status != "resolved":
return None, None
return resolution.content_title, resolution.anchor

def resolve_with_evidence(
self,
href: str,
link_text: str = "",
) -> LinkResolution:
"""href를 resolve하고 unresolved/ambiguous를 구분합니다."""
raw_href = href.strip()
if not raw_href:
return None, None
if _EXTERNAL_SCHEME_RE.match(raw_href):
return None, None
return LinkResolution("unresolved", raw_href)
if _EXTERNAL_SCHEME_RE.match(raw_href) or raw_href.startswith("//"):
return LinkResolution("external", raw_href)
if raw_href.startswith("#"):
return None, None
return LinkResolution("local_anchor", raw_href, anchor=raw_href[1:] or None)

path_part, anchor = self._split_anchor(raw_href)
if path_part in {".", "./"} and anchor:
return LinkResolution(
"local_anchor",
raw_href,
anchor=anchor,
)
normalized_path = self._normalize_path(path_part)

current_page_path = self._resolve_from_current_page(path_part)
if current_page_path:
title = self._path_to_title.get(current_page_path)
if title:
return title, anchor
resolution = self._resolution_for_entries(
raw_href,
self._path_to_entries.get(current_page_path, []),
anchor,
)
if resolution.status != "unresolved":
return resolution

if not normalized_path and link_text:
title = self._resolve_by_title(link_text)
if title:
return title, anchor
resolution = self._resolve_by_title(
raw_href,
link_text,
anchor,
)
if resolution.status != "unresolved":
return resolution

title = self._path_to_title.get(normalized_path)
if title:
return title, anchor
resolution = self._resolution_for_entries(
raw_href,
self._path_to_entries.get(normalized_path, []),
anchor,
)
if resolution.status != "unresolved":
return resolution

if link_text:
title = self._resolve_by_title(link_text)
if title:
return title, anchor
return None, None
return LinkResolution("unresolved", raw_href, anchor=anchor)

def _load_pages(self, pages: list[PageEntry]) -> None:
for page in pages:
normalized_path = self._normalize_path("/".join(page.path))
if normalized_path:
self._path_to_title[normalized_path] = page.title_orig
self._titles.add(page.title_orig)
self._path_to_entries.setdefault(normalized_path, []).append(page)
self._title_to_entries.setdefault(page.title_orig, []).append(page)
if page.page_id:
self._by_id[page.page_id] = page

Expand All @@ -149,19 +185,56 @@ def _normalize_path(path: str) -> str:
parts.append(segment)
return "/".join(parts)

def _resolve_by_title(self, link_text: str) -> Optional[str]:
@staticmethod
def _resolution_for_entries(
href: str,
entries: list[PageEntry],
anchor: Optional[str],
) -> LinkResolution:
if not entries:
return LinkResolution("unresolved", href, anchor=anchor)
page_ids = tuple(sorted({entry.page_id for entry in entries}))
if (
len(entries) != 1
or len(page_ids) != 1
or not page_ids[0]
):
return LinkResolution(
"ambiguous" if len(entries) > 1 else "unresolved",
href,
anchor=anchor,
candidate_page_ids=page_ids,
)
return LinkResolution(
"resolved",
href,
content_title=entries[0].title_orig,
anchor=anchor,
candidate_page_ids=page_ids,
)

def _resolve_by_title(
self,
href: str,
link_text: str,
anchor: Optional[str],
) -> LinkResolution:
title_candidate = link_text.strip()
if not title_candidate:
return None
return title_candidate if title_candidate in self._titles else None
return LinkResolution("unresolved", href, anchor=anchor)
return self._resolution_for_entries(
href,
self._title_to_entries.get(title_candidate, []),
anchor,
)

def _resolve_from_current_page(self, path_part: str) -> Optional[str]:
if self._current_page is None:
return None
if not path_part or path_part.startswith("/"):
return None
if not (path_part.startswith(".") or path_part.startswith("..")):
return None
if path_part in {".", "./"}:
return self._normalize_path("/".join(self._current_page.path))

current_dir = "/" + "/".join(self._current_page.path[:-1])
joined = posixpath.normpath(posixpath.join(current_dir, path_part))
Expand Down
Loading
Loading