mkdocs2confluence 0.9.0__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/pyproject.toml +1 -1
  3. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs2confluence.egg-info/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/emitter/xhtml.py +24 -5
  5. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/ir/nodes.py +13 -7
  6. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/footer.py +42 -6
  7. mkdocs2confluence-0.9.1/tests/test_footer.py +217 -0
  8. mkdocs2confluence-0.9.0/tests/test_footer.py +0 -119
  9. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/LICENSE +0 -0
  10. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/README.md +0 -0
  11. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/setup.cfg +0 -0
  12. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  13. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  14. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  15. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  16. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  17. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/__init__.py +0 -0
  18. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/cli.py +0 -0
  19. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  20. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  21. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/ir/document.py +0 -0
  22. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  23. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  24. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/loader/config.py +0 -0
  25. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  26. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  27. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/loader/page.py +0 -0
  28. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  29. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  30. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  31. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  32. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  33. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  34. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  35. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  36. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  37. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  38. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  39. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  40. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  41. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preview/render.py +0 -0
  42. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/preview/server.py +0 -0
  43. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  44. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  45. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  46. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  47. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  48. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/sync/command.py +0 -0
  49. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  50. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/sync/github.py +0 -0
  51. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  52. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/sync/state.py +0 -0
  53. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  54. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  55. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  56. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  57. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  58. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  59. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  60. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_abbrevs.py +0 -0
  61. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_cli.py +0 -0
  62. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_editlink.py +0 -0
  63. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_emitter.py +0 -0
  64. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_extra_css.py +0 -0
  65. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_frontmatter.py +0 -0
  66. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_icons.py +0 -0
  67. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_images.py +0 -0
  68. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_internallinks.py +0 -0
  69. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_ir.py +0 -0
  70. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_linkdefs.py +0 -0
  71. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_loader.py +0 -0
  72. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_mermaid.py +0 -0
  73. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_page_loader.py +0 -0
  74. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_parser.py +0 -0
  75. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_pdf.py +0 -0
  76. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_preprocess.py +0 -0
  77. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_preview.py +0 -0
  78. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_publish_client.py +0 -0
  79. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_publish_config.py +0 -0
  80. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_publish_pipeline.py +0 -0
  81. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_server.py +0 -0
  82. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_sync_anchoring.py +0 -0
  83. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_sync_command.py +0 -0
  84. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_sync_comments.py +0 -0
  85. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_sync_github.py +0 -0
  86. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_sync_state.py +0 -0
  87. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.1}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.9.0"
3
+ version = "0.9.1"
4
4
  description = "Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more"
5
5
  readme = "README.md"
6
6
  license = { text = "GPL-3.0-or-later" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -251,19 +251,38 @@ def _emit_front_matter(node: FrontMatter) -> str:
251
251
  return "".join(parts)
252
252
 
253
253
 
254
+ def _xml_safe(text: str) -> str:
255
+ """HTML-escape *text* and encode non-ASCII as XML numeric character references.
256
+
257
+ Confluence storage format is XML; raw non-ASCII bytes (e.g. UTF-8 encoded ·)
258
+ can be misinterpreted as Latin-1 by some Confluence versions, producing artefacts
259
+ like ``·``. Encoding everything outside ASCII as ``&#NNN;`` entities is safe
260
+ for all Confluence deployments.
261
+ """
262
+ return html.escape(text).encode("ascii", "xmlcharrefreplace").decode()
263
+
264
+
254
265
  def _emit_source_footer(node: SourceFooter) -> str:
255
- """Emit a 'Page source' panel macro with edit/history links and last-commit info."""
266
+ """Emit a borderless panel macro with edit/history links and last-commit info."""
256
267
  edit_href = html.escape(node.edit_url)
257
268
  links = f'<a href="{edit_href}">Edit this page</a>'
258
269
  if node.history_url:
259
270
  hist_href = html.escape(node.history_url)
260
- links += f' \u00b7 <a href="{hist_href}">View history</a>'
271
+ links += f' &#xB7; <a href="{hist_href}">View history</a>'
261
272
  body = f"<p>{links}</p>\n"
262
- if node.last_commit:
263
- body += f"<p><em>Last commit: {html.escape(node.last_commit)}</em></p>\n"
273
+ if node.commit_sha or node.commit_summary:
274
+ if node.commit_sha and node.commit_url:
275
+ sha_href = html.escape(node.commit_url)
276
+ sha_part = f'<a href="{sha_href}">{html.escape(node.commit_sha)}</a>'
277
+ elif node.commit_sha:
278
+ sha_part = html.escape(node.commit_sha)
279
+ else:
280
+ sha_part = ""
281
+ summary_part = _xml_safe(node.commit_summary) if node.commit_summary else ""
282
+ detail = " &#xB7; ".join(p for p in [sha_part, summary_part] if p)
283
+ body += f"<p><strong>Last commit:</strong> {detail}</p>\n"
264
284
  return (
265
285
  '<ac:structured-macro ac:name="panel">\n'
266
- ' <ac:parameter ac:name="title">Page source</ac:parameter>\n'
267
286
  ' <ac:parameter ac:name="borderStyle">solid</ac:parameter>\n'
268
287
  " <ac:rich-text-body>\n"
269
288
  f" {body}"
@@ -435,17 +435,23 @@ class SourceFooter(IRNode):
435
435
  Emitted as a Confluence ``panel`` macro at the bottom of the page.
436
436
 
437
437
  Attributes:
438
- edit_url: URL to edit the source file (e.g. GitHub edit link).
439
- history_url: URL to the file's commit history. ``None`` when it
440
- cannot be derived from ``edit_url``.
441
- last_commit: Human-readable last-commit summary from ``git log``,
442
- e.g. ``"abc1234 · Fix typo · Jane · 2 days ago"``.
443
- ``None`` when git is unavailable or the file is untracked.
438
+ edit_url: URL to edit the source file (e.g. GitHub edit link).
439
+ history_url: URL to the file's commit history. ``None`` when it
440
+ cannot be derived from ``edit_url``.
441
+ commit_sha: Short commit hash (e.g. ``"abc1234"``). ``None`` when
442
+ git is unavailable or the file is untracked.
443
+ commit_url: URL to the specific commit on the VCS host. ``None``
444
+ when the URL cannot be derived.
445
+ commit_summary: Commit message, author, and relative date, e.g.
446
+ ``"Fix typo · Jane · 2 days ago"``. ``None`` when git
447
+ is unavailable or the file is untracked.
444
448
  """
445
449
 
446
450
  edit_url: str
447
451
  history_url: str | None = None
448
- last_commit: str | None = None
452
+ commit_sha: str | None = None
453
+ commit_url: str | None = None
454
+ commit_summary: str | None = None
449
455
 
450
456
 
451
457
  @dataclass(frozen=True)
@@ -11,6 +11,9 @@ import subprocess
11
11
 
12
12
  from mkdocs_to_confluence.ir.nodes import SourceFooter
13
13
 
14
+ # separator used in git --format to allow reliable splitting
15
+ _GIT_SEP = "\x1f" # ASCII unit separator — never appears in commit messages
16
+
14
17
 
15
18
  def _derive_history_url(edit_url: str) -> str | None:
16
19
  """Derive a commit-history URL from a VCS edit URL.
@@ -25,18 +28,34 @@ def _derive_history_url(edit_url: str) -> str | None:
25
28
  return None
26
29
 
27
30
 
28
- def _last_commit(abs_path: str) -> str | None:
29
- """Return a human-readable last-commit summary for *abs_path*.
31
+ def _derive_commit_url(edit_url: str, sha: str) -> str | None:
32
+ """Derive a direct commit URL from a VCS edit URL and a commit SHA.
30
33
 
31
- Runs ``git log -1 --format=... --date=relative`` on the file.
34
+ Supports GitHub (``/edit/``) and GitLab (``/-/edit/``).
35
+ Returns ``None`` for any other URL shape.
36
+ """
37
+ if "/-/edit/" in edit_url:
38
+ base = edit_url.split("/-/edit/")[0]
39
+ return f"{base}/-/commit/{sha}"
40
+ if "/edit/" in edit_url:
41
+ base = edit_url.split("/edit/")[0]
42
+ return f"{base}/commit/{sha}"
43
+ return None
44
+
45
+
46
+ def _last_commit_info(abs_path: str) -> tuple[str, str] | None:
47
+ """Return ``(short_sha, summary)`` for the last commit touching *abs_path*.
48
+
49
+ *summary* is ``"message · author · relative_date"``.
32
50
  Returns ``None`` when git is unavailable, the path is untracked, or the
33
51
  command fails for any reason.
34
52
  """
53
+ sep = _GIT_SEP
35
54
  try:
36
55
  result = subprocess.run(
37
56
  [
38
57
  "git", "log", "-1",
39
- "--format=%h \u00b7 %s \u00b7 %an \u00b7 %ad",
58
+ f"--format=%h{sep}%s{sep}%an{sep}%ad",
40
59
  "--date=relative",
41
60
  "--",
42
61
  abs_path,
@@ -46,7 +65,13 @@ def _last_commit(abs_path: str) -> str | None:
46
65
  timeout=5,
47
66
  )
48
67
  output = result.stdout.strip()
49
- return output if output else None
68
+ if not output:
69
+ return None
70
+ parts = output.split(sep, 3)
71
+ if len(parts) < 4:
72
+ return None
73
+ sha, message, author, date = parts
74
+ return sha, f"{message} \u00b7 {author} \u00b7 {date}"
50
75
  except Exception: # noqa: BLE001
51
76
  return None
52
77
 
@@ -62,8 +87,19 @@ def build_source_footer(edit_url: str, abs_path: str) -> SourceFooter:
62
87
  Absolute filesystem path to the source Markdown file. Used to
63
88
  query ``git log`` for the last-commit summary.
64
89
  """
90
+ commit_info = _last_commit_info(abs_path)
91
+ if commit_info is not None:
92
+ sha, summary = commit_info
93
+ commit_url = _derive_commit_url(edit_url, sha)
94
+ else:
95
+ sha = None
96
+ summary = None
97
+ commit_url = None
98
+
65
99
  return SourceFooter(
66
100
  edit_url=edit_url,
67
101
  history_url=_derive_history_url(edit_url),
68
- last_commit=_last_commit(abs_path),
102
+ commit_sha=sha,
103
+ commit_url=commit_url,
104
+ commit_summary=summary,
69
105
  )
@@ -0,0 +1,217 @@
1
+ """Tests for transforms/footer.py and the SourceFooter emitter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import patch
6
+
7
+ from mkdocs_to_confluence.emitter.xhtml import emit
8
+ from mkdocs_to_confluence.ir.nodes import SourceFooter
9
+ from mkdocs_to_confluence.transforms.footer import (
10
+ _derive_commit_url,
11
+ _derive_history_url,
12
+ _last_commit_info,
13
+ build_source_footer,
14
+ )
15
+
16
+ # ── _derive_history_url ───────────────────────────────────────────────────────
17
+
18
+
19
+ def test_derive_history_url_github():
20
+ url = "https://github.com/org/repo/edit/main/docs/guide/setup.md"
21
+ assert _derive_history_url(url) == "https://github.com/org/repo/commits/main/docs/guide/setup.md"
22
+
23
+
24
+ def test_derive_history_url_gitlab():
25
+ url = "https://gitlab.com/org/repo/-/edit/main/docs/guide/setup.md"
26
+ assert _derive_history_url(url) == "https://gitlab.com/org/repo/-/commits/main/docs/guide/setup.md"
27
+
28
+
29
+ def test_derive_history_url_unknown_returns_none():
30
+ url = "https://bitbucket.org/org/repo/src/main/docs/guide/setup.md"
31
+ assert _derive_history_url(url) is None
32
+
33
+
34
+ def test_derive_history_url_empty_string():
35
+ assert _derive_history_url("") is None
36
+
37
+
38
+ # ── _derive_commit_url ────────────────────────────────────────────────────────
39
+
40
+
41
+ def test_derive_commit_url_github():
42
+ url = "https://github.com/org/repo/edit/main/docs/page.md"
43
+ assert _derive_commit_url(url, "abc1234") == "https://github.com/org/repo/commit/abc1234"
44
+
45
+
46
+ def test_derive_commit_url_gitlab():
47
+ url = "https://gitlab.com/org/repo/-/edit/main/docs/page.md"
48
+ assert _derive_commit_url(url, "abc1234") == "https://gitlab.com/org/repo/-/commit/abc1234"
49
+
50
+
51
+ def test_derive_commit_url_unknown_returns_none():
52
+ url = "https://bitbucket.org/org/repo/src/main/docs/page.md"
53
+ assert _derive_commit_url(url, "abc1234") is None
54
+
55
+
56
+ # ── _last_commit_info ─────────────────────────────────────────────────────────
57
+
58
+
59
+ def test_last_commit_info_returns_sha_and_summary(tmp_path):
60
+ fake_file = str(tmp_path / "docs" / "page.md")
61
+ sep = "\x1f"
62
+ with patch("mkdocs_to_confluence.transforms.footer.subprocess.run") as mock_run:
63
+ mock_run.return_value.stdout = f"abc1234{sep}Fix typo{sep}Jane{sep}2 days ago\n"
64
+ result = _last_commit_info(fake_file)
65
+ assert result is not None
66
+ sha, summary = result
67
+ assert sha == "abc1234"
68
+ assert "Fix typo" in summary
69
+ assert "Jane" in summary
70
+ assert "2 days ago" in summary
71
+
72
+
73
+ def test_last_commit_info_returns_none_when_empty(tmp_path):
74
+ fake_file = str(tmp_path / "docs" / "untracked.md")
75
+ with patch("mkdocs_to_confluence.transforms.footer.subprocess.run") as mock_run:
76
+ mock_run.return_value.stdout = ""
77
+ result = _last_commit_info(fake_file)
78
+ assert result is None
79
+
80
+
81
+ def test_last_commit_info_returns_none_on_exception(tmp_path):
82
+ fake_file = str(tmp_path / "docs" / "page.md")
83
+ with patch("mkdocs_to_confluence.transforms.footer.subprocess.run", side_effect=FileNotFoundError):
84
+ result = _last_commit_info(fake_file)
85
+ assert result is None
86
+
87
+
88
+ # ── build_source_footer ───────────────────────────────────────────────────────
89
+
90
+
91
+ def test_build_source_footer_full(tmp_path):
92
+ edit_url = "https://github.com/org/repo/edit/main/docs/guide.md"
93
+ abs_path = str(tmp_path / "docs" / "guide.md")
94
+ with patch("mkdocs_to_confluence.transforms.footer._last_commit_info",
95
+ return_value=("abc1234", "Fix typo · Jane · 1 day ago")):
96
+ footer = build_source_footer(edit_url, abs_path)
97
+ assert footer.edit_url == edit_url
98
+ assert footer.history_url == "https://github.com/org/repo/commits/main/docs/guide.md"
99
+ assert footer.commit_sha == "abc1234"
100
+ assert footer.commit_url == "https://github.com/org/repo/commit/abc1234"
101
+ assert footer.commit_summary == "Fix typo · Jane · 1 day ago"
102
+
103
+
104
+ def test_build_source_footer_no_commit(tmp_path):
105
+ edit_url = "https://github.com/org/repo/edit/main/docs/guide.md"
106
+ abs_path = str(tmp_path / "docs" / "guide.md")
107
+ with patch("mkdocs_to_confluence.transforms.footer._last_commit_info", return_value=None):
108
+ footer = build_source_footer(edit_url, abs_path)
109
+ assert footer.commit_sha is None
110
+ assert footer.commit_url is None
111
+ assert footer.commit_summary is None
112
+ assert footer.history_url is not None
113
+
114
+
115
+ # ── _emit_source_footer ───────────────────────────────────────────────────────
116
+
117
+
118
+ def test_emit_footer_contains_edit_link():
119
+ footer = SourceFooter(
120
+ edit_url="https://github.com/org/repo/edit/main/docs/page.md",
121
+ history_url="https://github.com/org/repo/commits/main/docs/page.md",
122
+ commit_sha="abc1234",
123
+ commit_url="https://github.com/org/repo/commit/abc1234",
124
+ commit_summary="Fix typo · Jane · 2 days ago",
125
+ )
126
+ out = emit((footer,))
127
+ assert "Edit this page" in out
128
+ assert "View history" in out
129
+ assert "abc1234" in out
130
+ assert "Fix typo" in out
131
+ assert 'ac:name="panel"' in out
132
+
133
+
134
+ def test_emit_footer_sha_is_hyperlink():
135
+ footer = SourceFooter(
136
+ edit_url="https://github.com/org/repo/edit/main/docs/page.md",
137
+ history_url=None,
138
+ commit_sha="abc1234",
139
+ commit_url="https://github.com/org/repo/commit/abc1234",
140
+ commit_summary="Fix typo · Jane · today",
141
+ )
142
+ out = emit((footer,))
143
+ assert 'href="https://github.com/org/repo/commit/abc1234"' in out
144
+ assert ">abc1234<" in out
145
+
146
+
147
+ def test_emit_footer_last_commit_bold():
148
+ footer = SourceFooter(
149
+ edit_url="https://github.com/org/repo/edit/main/docs/page.md",
150
+ history_url=None,
151
+ commit_sha="abc1234",
152
+ commit_url="https://github.com/org/repo/commit/abc1234",
153
+ commit_summary="Fix typo · Jane · today",
154
+ )
155
+ out = emit((footer,))
156
+ assert "<strong>Last commit:</strong>" in out
157
+
158
+
159
+ def test_emit_footer_no_panel_title():
160
+ footer = SourceFooter(
161
+ edit_url="https://github.com/org/repo/edit/main/docs/page.md",
162
+ )
163
+ out = emit((footer,))
164
+ assert 'ac:name="title"' not in out
165
+
166
+
167
+ def test_emit_footer_no_history_url():
168
+ footer = SourceFooter(
169
+ edit_url="https://example.com/edit/docs/page.md",
170
+ history_url=None,
171
+ commit_sha=None,
172
+ commit_url=None,
173
+ commit_summary=None,
174
+ )
175
+ out = emit((footer,))
176
+ assert "Edit this page" in out
177
+ assert "View history" not in out
178
+ assert "Last commit" not in out
179
+
180
+
181
+ def test_emit_footer_escapes_html():
182
+ footer = SourceFooter(
183
+ edit_url='https://example.com/edit/path?a=1&b=2',
184
+ history_url=None,
185
+ commit_sha=None,
186
+ commit_url=None,
187
+ commit_summary='<script>alert(1)</script> · Jane · today',
188
+ )
189
+ out = emit((footer,))
190
+ assert "<script>" not in out
191
+ assert "&lt;script&gt;" in out
192
+ assert "a=1&amp;b=2" in out
193
+
194
+
195
+ def test_emit_footer_non_ascii_encoded_as_entity():
196
+ """Non-ASCII characters in commit_summary are encoded as XML numeric entities."""
197
+ footer = SourceFooter(
198
+ edit_url="https://github.com/org/repo/edit/main/docs/page.md",
199
+ history_url=None,
200
+ commit_sha=None,
201
+ commit_url=None,
202
+ commit_summary="Fix caf\u00e9 · \u00c5ngstr\u00f6m · 2 days ago",
203
+ )
204
+ out = emit((footer,))
205
+ assert "caf\u00e9" not in out
206
+ assert "&#233;" in out or "&#xe9;" in out.lower()
207
+
208
+
209
+ def test_emit_footer_separator_is_entity():
210
+ """The · separator between Edit/View history links is an XML entity, not raw UTF-8."""
211
+ footer = SourceFooter(
212
+ edit_url="https://github.com/org/repo/edit/main/docs/page.md",
213
+ history_url="https://github.com/org/repo/commits/main/docs/page.md",
214
+ )
215
+ out = emit((footer,))
216
+ assert "&#xB7;" in out
217
+ assert "\u00b7" not in out
@@ -1,119 +0,0 @@
1
- """Tests for transforms/footer.py and the SourceFooter emitter."""
2
-
3
- from __future__ import annotations
4
-
5
- from unittest.mock import patch
6
-
7
- from mkdocs_to_confluence.emitter.xhtml import emit
8
- from mkdocs_to_confluence.ir.nodes import SourceFooter
9
- from mkdocs_to_confluence.transforms.footer import _derive_history_url, _last_commit, build_source_footer
10
-
11
- # ── _derive_history_url ───────────────────────────────────────────────────────
12
-
13
-
14
- def test_derive_history_url_github():
15
- url = "https://github.com/org/repo/edit/main/docs/guide/setup.md"
16
- assert _derive_history_url(url) == "https://github.com/org/repo/commits/main/docs/guide/setup.md"
17
-
18
-
19
- def test_derive_history_url_gitlab():
20
- url = "https://gitlab.com/org/repo/-/edit/main/docs/guide/setup.md"
21
- assert _derive_history_url(url) == "https://gitlab.com/org/repo/-/commits/main/docs/guide/setup.md"
22
-
23
-
24
- def test_derive_history_url_unknown_returns_none():
25
- url = "https://bitbucket.org/org/repo/src/main/docs/guide/setup.md"
26
- assert _derive_history_url(url) is None
27
-
28
-
29
- def test_derive_history_url_empty_string():
30
- assert _derive_history_url("") is None
31
-
32
-
33
- # ── _last_commit ──────────────────────────────────────────────────────────────
34
-
35
-
36
- def test_last_commit_returns_output(tmp_path):
37
- fake_file = str(tmp_path / "docs" / "page.md")
38
- with patch("mkdocs_to_confluence.transforms.footer.subprocess.run") as mock_run:
39
- mock_run.return_value.stdout = "abc1234 · Fix typo · Jane · 2 days ago\n"
40
- result = _last_commit(fake_file)
41
- assert result == "abc1234 · Fix typo · Jane · 2 days ago"
42
-
43
-
44
- def test_last_commit_returns_none_when_empty(tmp_path):
45
- fake_file = str(tmp_path / "docs" / "untracked.md")
46
- with patch("mkdocs_to_confluence.transforms.footer.subprocess.run") as mock_run:
47
- mock_run.return_value.stdout = ""
48
- result = _last_commit(fake_file)
49
- assert result is None
50
-
51
-
52
- def test_last_commit_returns_none_on_exception(tmp_path):
53
- fake_file = str(tmp_path / "docs" / "page.md")
54
- with patch("mkdocs_to_confluence.transforms.footer.subprocess.run", side_effect=FileNotFoundError):
55
- result = _last_commit(fake_file)
56
- assert result is None
57
-
58
-
59
- # ── build_source_footer ───────────────────────────────────────────────────────
60
-
61
-
62
- def test_build_source_footer_full(tmp_path):
63
- edit_url = "https://github.com/org/repo/edit/main/docs/guide.md"
64
- abs_path = str(tmp_path / "docs" / "guide.md")
65
- with patch("mkdocs_to_confluence.transforms.footer._last_commit", return_value="abc · msg · Jane · 1 day ago"):
66
- footer = build_source_footer(edit_url, abs_path)
67
- assert footer.edit_url == edit_url
68
- assert footer.history_url == "https://github.com/org/repo/commits/main/docs/guide.md"
69
- assert footer.last_commit == "abc · msg · Jane · 1 day ago"
70
-
71
-
72
- def test_build_source_footer_no_commit(tmp_path):
73
- edit_url = "https://github.com/org/repo/edit/main/docs/guide.md"
74
- abs_path = str(tmp_path / "docs" / "guide.md")
75
- with patch("mkdocs_to_confluence.transforms.footer._last_commit", return_value=None):
76
- footer = build_source_footer(edit_url, abs_path)
77
- assert footer.last_commit is None
78
- assert footer.history_url is not None
79
-
80
-
81
- # ── _emit_source_footer ───────────────────────────────────────────────────────
82
-
83
-
84
- def test_emit_footer_contains_edit_link():
85
- footer = SourceFooter(
86
- edit_url="https://github.com/org/repo/edit/main/docs/page.md",
87
- history_url="https://github.com/org/repo/commits/main/docs/page.md",
88
- last_commit="abc1234 · Fix typo · Jane · 2 days ago",
89
- )
90
- out = emit((footer,))
91
- assert "Edit this page" in out
92
- assert "View history" in out
93
- assert "abc1234" in out
94
- assert "Fix typo" in out
95
- assert 'ac:name="panel"' in out
96
-
97
-
98
- def test_emit_footer_no_history_url():
99
- footer = SourceFooter(
100
- edit_url="https://example.com/edit/docs/page.md",
101
- history_url=None,
102
- last_commit=None,
103
- )
104
- out = emit((footer,))
105
- assert "Edit this page" in out
106
- assert "View history" not in out
107
- assert "Last commit" not in out
108
-
109
-
110
- def test_emit_footer_escapes_html():
111
- footer = SourceFooter(
112
- edit_url='https://example.com/edit/path?a=1&b=2',
113
- history_url=None,
114
- last_commit='abc · <script>alert(1)</script> · Jane · today',
115
- )
116
- out = emit((footer,))
117
- assert "<script>" not in out
118
- assert "&lt;script&gt;" in out
119
- assert "a=1&amp;b=2" in out