mkdocs2confluence 0.8.4__tar.gz → 0.9.0__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 (86) hide show
  1. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/PKG-INFO +10 -2
  2. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/README.md +9 -1
  3. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/pyproject.toml +1 -1
  4. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs2confluence.egg-info/PKG-INFO +10 -2
  5. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs2confluence.egg-info/SOURCES.txt +2 -0
  6. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/emitter/xhtml.py +25 -32
  7. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/ir/nodes.py +20 -4
  8. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/publisher/pipeline.py +7 -2
  9. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/transforms/editlink.py +14 -15
  10. mkdocs2confluence-0.9.0/src/mkdocs_to_confluence/transforms/footer.py +69 -0
  11. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_editlink.py +19 -15
  12. mkdocs2confluence-0.9.0/tests/test_footer.py +119 -0
  13. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_frontmatter.py +1 -68
  14. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/LICENSE +0 -0
  15. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/setup.cfg +0 -0
  16. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  17. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  18. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  19. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  20. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/__init__.py +0 -0
  21. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/cli.py +0 -0
  22. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  23. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  24. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/ir/document.py +0 -0
  25. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  26. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  27. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/loader/config.py +0 -0
  28. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  29. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  30. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/loader/page.py +0 -0
  31. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  32. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  33. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  34. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  35. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  36. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  37. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  38. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  39. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  40. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  41. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  42. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  43. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  44. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preview/render.py +0 -0
  45. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/preview/server.py +0 -0
  46. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  47. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  48. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  49. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  50. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/sync/command.py +0 -0
  51. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  52. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/sync/github.py +0 -0
  53. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  54. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/sync/state.py +0 -0
  55. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  56. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  57. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  58. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  59. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  60. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  61. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_abbrevs.py +0 -0
  62. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_cli.py +0 -0
  63. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_emitter.py +0 -0
  64. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_extra_css.py +0 -0
  65. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_icons.py +0 -0
  66. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_images.py +0 -0
  67. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_internallinks.py +0 -0
  68. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_ir.py +0 -0
  69. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_linkdefs.py +0 -0
  70. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_loader.py +0 -0
  71. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_mermaid.py +0 -0
  72. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_page_loader.py +0 -0
  73. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_parser.py +0 -0
  74. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_pdf.py +0 -0
  75. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_preprocess.py +0 -0
  76. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_preview.py +0 -0
  77. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_publish_client.py +0 -0
  78. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_publish_config.py +0 -0
  79. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_publish_pipeline.py +0 -0
  80. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_server.py +0 -0
  81. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_sync_anchoring.py +0 -0
  82. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_sync_command.py +0 -0
  83. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_sync_comments.py +0 -0
  84. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_sync_github.py +0 -0
  85. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_sync_state.py +0 -0
  86. {mkdocs2confluence-0.8.4 → mkdocs2confluence-0.9.0}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.8.4
3
+ Version: 0.9.0
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
@@ -423,7 +423,15 @@ status: in-progress
423
423
  | `status` | Sets the Confluence page status badge — common values: `rough-draft`, `in-progress`, `ready-for-review` (space-specific values are also supported). Not shown in the properties table. |
424
424
  | *other fields* | Title-cased key, value stringified |
425
425
 
426
- If `repo_url` + `edit_uri` are set in `mkdocs.yml`, an **Edit Source** row links to the source file. If `site_url` is set, a **Published Page** row links to the rendered MkDocs site.
426
+ If `site_url` is set in `mkdocs.yml`, a **Published Page** row links to the rendered MkDocs site.
427
+
428
+ ### Source footer
429
+
430
+ When `repo_url` + `edit_uri` are set in `mkdocs.yml`, a **Page source** footer panel is appended to the bottom of each published page containing:
431
+
432
+ - **Edit this page** — links to the source file in your VCS (GitHub/GitLab/etc.)
433
+ - **View history** — links to the file's commit history (derived automatically for GitHub and GitLab URLs)
434
+ - **Last commit** — short commit SHA, message, author, and relative date from `git log` at publish time (omitted if git is unavailable or the file is untracked)
427
435
 
428
436
  ### Abbreviation expansion
429
437
 
@@ -383,7 +383,15 @@ status: in-progress
383
383
  | `status` | Sets the Confluence page status badge — common values: `rough-draft`, `in-progress`, `ready-for-review` (space-specific values are also supported). Not shown in the properties table. |
384
384
  | *other fields* | Title-cased key, value stringified |
385
385
 
386
- If `repo_url` + `edit_uri` are set in `mkdocs.yml`, an **Edit Source** row links to the source file. If `site_url` is set, a **Published Page** row links to the rendered MkDocs site.
386
+ If `site_url` is set in `mkdocs.yml`, a **Published Page** row links to the rendered MkDocs site.
387
+
388
+ ### Source footer
389
+
390
+ When `repo_url` + `edit_uri` are set in `mkdocs.yml`, a **Page source** footer panel is appended to the bottom of each published page containing:
391
+
392
+ - **Edit this page** — links to the source file in your VCS (GitHub/GitLab/etc.)
393
+ - **View history** — links to the file's commit history (derived automatically for GitHub and GitLab URLs)
394
+ - **Last commit** — short commit SHA, message, author, and relative date from `git log` at publish time (omitted if git is unavailable or the file is untracked)
387
395
 
388
396
  ### Abbreviation expansion
389
397
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.8.4"
3
+ version = "0.9.0"
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.8.4
3
+ Version: 0.9.0
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
@@ -423,7 +423,15 @@ status: in-progress
423
423
  | `status` | Sets the Confluence page status badge — common values: `rough-draft`, `in-progress`, `ready-for-review` (space-specific values are also supported). Not shown in the properties table. |
424
424
  | *other fields* | Title-cased key, value stringified |
425
425
 
426
- If `repo_url` + `edit_uri` are set in `mkdocs.yml`, an **Edit Source** row links to the source file. If `site_url` is set, a **Published Page** row links to the rendered MkDocs site.
426
+ If `site_url` is set in `mkdocs.yml`, a **Published Page** row links to the rendered MkDocs site.
427
+
428
+ ### Source footer
429
+
430
+ When `repo_url` + `edit_uri` are set in `mkdocs.yml`, a **Page source** footer panel is appended to the bottom of each published page containing:
431
+
432
+ - **Edit this page** — links to the source file in your VCS (GitHub/GitLab/etc.)
433
+ - **View history** — links to the file's commit history (derived automatically for GitHub and GitLab URLs)
434
+ - **Last commit** — short commit SHA, message, author, and relative date from `git log` at publish time (omitted if git is unavailable or the file is untracked)
427
435
 
428
436
  ### Abbreviation expansion
429
437
 
@@ -49,6 +49,7 @@ src/mkdocs_to_confluence/transforms/__init__.py
49
49
  src/mkdocs_to_confluence/transforms/abbrevs.py
50
50
  src/mkdocs_to_confluence/transforms/assets.py
51
51
  src/mkdocs_to_confluence/transforms/editlink.py
52
+ src/mkdocs_to_confluence/transforms/footer.py
52
53
  src/mkdocs_to_confluence/transforms/images.py
53
54
  src/mkdocs_to_confluence/transforms/internallinks.py
54
55
  src/mkdocs_to_confluence/transforms/mermaid.py
@@ -57,6 +58,7 @@ tests/test_cli.py
57
58
  tests/test_editlink.py
58
59
  tests/test_emitter.py
59
60
  tests/test_extra_css.py
61
+ tests/test_footer.py
60
62
  tests/test_frontmatter.py
61
63
  tests/test_icons.py
62
64
  tests/test_images.py
@@ -19,7 +19,6 @@ from __future__ import annotations
19
19
  import html
20
20
  from pathlib import Path
21
21
  from typing import Sequence
22
- from urllib.parse import urlparse
23
22
 
24
23
  from mkdocs_to_confluence.ir.nodes import (
25
24
  AbbrevFootnoteNode,
@@ -52,6 +51,7 @@ from mkdocs_to_confluence.ir.nodes import (
52
51
  RawHTML,
53
52
  RawInlineHtml,
54
53
  Section,
54
+ SourceFooter,
55
55
  StrikethroughNode,
56
56
  SubscriptNode,
57
57
  SuperscriptNode,
@@ -196,6 +196,8 @@ def _emit_node(node: IRNode) -> str:
196
196
  return _emit_abbrev_glossary_block(node)
197
197
  if isinstance(node, GridCards):
198
198
  return _emit_grid_cards(node)
199
+ if isinstance(node, SourceFooter):
200
+ return _emit_source_footer(node)
199
201
  if isinstance(node, UnsupportedBlock):
200
202
  return _emit_unsupported(node)
201
203
  # Inline nodes at block level (shouldn't normally appear, but be safe)
@@ -216,29 +218,6 @@ def _emit_section(node: Section) -> str:
216
218
  return heading + body
217
219
 
218
220
 
219
- def _source_link_label(url: str) -> str:
220
- """Return a platform-aware label for the source edit link.
221
-
222
- Detects GitHub, GitLab and Bitbucket from the URL hostname and returns
223
- "Edit in <Platform> ↗". Falls back to "Edit source ↗" for anything else.
224
- Only the proven-safe ↗ arrow is used — no emoji that may render as ??? on
225
- Confluence Cloud.
226
- """
227
- try:
228
- hostname = urlparse(url).hostname or ""
229
- except ValueError:
230
- hostname = ""
231
- if hostname == "github.com" or hostname.endswith(".github.com"):
232
- platform = "GitHub"
233
- elif hostname == "gitlab.com" or hostname.endswith(".gitlab.com") or "gitlab" in hostname:
234
- platform = "GitLab"
235
- elif hostname == "bitbucket.org" or hostname.endswith(".bitbucket.org"):
236
- platform = "Bitbucket"
237
- else:
238
- return "Edit source \u2197"
239
- return f"Edit in {platform} \u2197"
240
-
241
-
242
221
  def _emit_front_matter(node: FrontMatter) -> str:
243
222
  """Emit front matter as an optional subtitle paragraph + Page Properties macro."""
244
223
  parts: list[str] = []
@@ -246,20 +225,13 @@ def _emit_front_matter(node: FrontMatter) -> str:
246
225
  if node.subtitle:
247
226
  parts.append(f"<p><em>{html.escape(node.subtitle)}</em></p>\n")
248
227
 
249
- has_table = node.properties or node.source_url or node.site_url
228
+ has_table = node.properties or node.site_url
250
229
  if has_table:
251
230
  rows = "".join(
252
231
  f" <tr><th>{html.escape(display)}</th>"
253
232
  f"<td>{html.escape(value)}</td></tr>\n"
254
233
  for display, value in node.properties
255
234
  )
256
- if node.source_url:
257
- label = html.escape(_source_link_label(node.source_url))
258
- href = html.escape(node.source_url)
259
- rows += (
260
- f' <tr><th>Source</th>'
261
- f'<td><a href="{href}">{label}</a></td></tr>\n'
262
- )
263
235
  if node.site_url:
264
236
  href = html.escape(node.site_url)
265
237
  rows += (
@@ -279,6 +251,27 @@ def _emit_front_matter(node: FrontMatter) -> str:
279
251
  return "".join(parts)
280
252
 
281
253
 
254
+ def _emit_source_footer(node: SourceFooter) -> str:
255
+ """Emit a 'Page source' panel macro with edit/history links and last-commit info."""
256
+ edit_href = html.escape(node.edit_url)
257
+ links = f'<a href="{edit_href}">Edit this page</a>'
258
+ if node.history_url:
259
+ hist_href = html.escape(node.history_url)
260
+ links += f' \u00b7 <a href="{hist_href}">View history</a>'
261
+ 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"
264
+ return (
265
+ '<ac:structured-macro ac:name="panel">\n'
266
+ ' <ac:parameter ac:name="title">Page source</ac:parameter>\n'
267
+ ' <ac:parameter ac:name="borderStyle">solid</ac:parameter>\n'
268
+ " <ac:rich-text-body>\n"
269
+ f" {body}"
270
+ " </ac:rich-text-body>\n"
271
+ "</ac:structured-macro>\n"
272
+ )
273
+
274
+
282
275
  def _emit_paragraph(node: Paragraph) -> str:
283
276
  return f"<p>{_emit_inlines(node.children)}</p>\n"
284
277
 
@@ -413,9 +413,6 @@ class FrontMatter(IRNode):
413
413
  front matter extractor.
414
414
  labels: Confluence page labels derived from the ``tags:`` field.
415
415
  Applied via the REST API at publish time (not in XHTML).
416
- source_url: Optional URL to the source file in the version-control
417
- repository. Rendered as a clickable link row ("Source")
418
- at the bottom of the Page Properties table.
419
416
  site_url: Optional URL to the rendered page on the published MkDocs
420
417
  site. Rendered as a "Published Page" row in the table.
421
418
  """
@@ -424,7 +421,6 @@ class FrontMatter(IRNode):
424
421
  subtitle: str | None
425
422
  properties: tuple[tuple[str, str], ...]
426
423
  labels: tuple[str, ...]
427
- source_url: str | None = None
428
424
  site_url: str | None = None
429
425
  confluence_status: str | None = None
430
426
 
@@ -432,6 +428,26 @@ class FrontMatter(IRNode):
432
428
  # ── Abbreviation footnotes ────────────────────────────────────────────────────
433
429
 
434
430
 
431
+ @dataclass(frozen=True)
432
+ class SourceFooter(IRNode):
433
+ """Footer panel showing source-control links and last-commit info.
434
+
435
+ Emitted as a Confluence ``panel`` macro at the bottom of the page.
436
+
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.
444
+ """
445
+
446
+ edit_url: str
447
+ history_url: str | None = None
448
+ last_commit: str | None = None
449
+
450
+
435
451
  @dataclass(frozen=True)
436
452
  class AbbrevFootnoteNode(IRNode):
437
453
  """Inline: abbreviated term with a superscript anchor-link to the glossary.
@@ -49,6 +49,7 @@ from mkdocs_to_confluence.publisher.client import ConfluenceError
49
49
  from mkdocs_to_confluence.transforms.abbrevs import apply_abbreviations
50
50
  from mkdocs_to_confluence.transforms.assets import _make_attachment_name, resolve_local_assets
51
51
  from mkdocs_to_confluence.transforms.editlink import attach_source_url
52
+ from mkdocs_to_confluence.transforms.footer import build_source_footer
52
53
  from mkdocs_to_confluence.transforms.internallinks import build_link_map, resolve_internal_links
53
54
  from mkdocs_to_confluence.transforms.mermaid import DEFAULT_KROKI_URL, render_mermaid_diagrams
54
55
 
@@ -187,8 +188,12 @@ def compile_page(
187
188
  ir_nodes = (front_matter,) + ir_nodes
188
189
  edit_url = config.page_edit_url(node.docs_path or "")
189
190
  site_url = config.page_site_url(node.docs_path or "")
190
- if edit_url or site_url:
191
- ir_nodes = attach_source_url(ir_nodes, edit_url or "", site_url)
191
+ if site_url:
192
+ ir_nodes = attach_source_url(ir_nodes, "", site_url)
193
+ if edit_url:
194
+ abs_path = str(config.docs_dir / (node.docs_path or ""))
195
+ footer = build_source_footer(edit_url, abs_path)
196
+ ir_nodes = ir_nodes + (footer,)
192
197
 
193
198
  # Extract labels and confluence_status from FrontMatter node.
194
199
  labels: tuple[str, ...] = ()
@@ -1,16 +1,13 @@
1
1
  """Edit-link injection transform.
2
2
 
3
- Attaches the source-file URL and optional published-page URL to the page's
4
- :class:`~ir.nodes.FrontMatter` node so they appear as rows in the Confluence
5
- Page Properties table.
6
-
7
- - **Source** row: the VCS edit URL (from ``repo_url`` + ``edit_uri``)
8
- - **Published Page** row: the rendered MkDocs URL (from ``site_url``)
3
+ Attaches the published-page URL (from ``site_url``) to the page's
4
+ :class:`~ir.nodes.FrontMatter` node so it appears as a row in the
5
+ Confluence Page Properties table.
9
6
 
10
7
  If the page has no front matter a minimal :class:`~ir.nodes.FrontMatter`
11
- node is created and prepended so the rows always appear.
8
+ node is created and prepended so the row always appears.
12
9
 
13
- Rows are only injected when the corresponding URL is non-empty.
10
+ The row is only injected when the URL is non-empty.
14
11
  """
15
12
 
16
13
  from __future__ import annotations
@@ -23,42 +20,44 @@ def attach_source_url(
23
20
  edit_url: str,
24
21
  site_url: str | None = None,
25
22
  ) -> tuple[IRNode, ...]:
26
- """Attach *edit_url* (and optionally *site_url*) to the page's FrontMatter.
23
+ """Attach *site_url* to the page's FrontMatter for the Page Properties table.
27
24
 
28
25
  Parameters
29
26
  ----------
30
27
  nodes:
31
28
  Top-level IR nodes for the page.
32
29
  edit_url:
33
- Full URL to the source file edit view (e.g. GitHub edit URL).
30
+ Kept for backwards compatibility; no longer stored on FrontMatter.
31
+ Pass an empty string or ``None`` — it is ignored.
34
32
  site_url:
35
33
  Full URL to the rendered page on the MkDocs site. When provided,
36
- a "Published Page" row is added after the "Source" row.
34
+ a "Published Page" row is added to the Page Properties table.
37
35
 
38
36
  Returns
39
37
  -------
40
38
  tuple[IRNode, ...]
41
- New nodes tuple with URL(s) attached to the FrontMatter.
39
+ New nodes tuple with ``site_url`` attached to the FrontMatter.
42
40
  """
41
+ if not site_url:
42
+ return nodes
43
+
43
44
  if nodes and isinstance(nodes[0], FrontMatter):
44
45
  updated = FrontMatter(
45
46
  title=nodes[0].title,
46
47
  subtitle=nodes[0].subtitle,
47
48
  properties=nodes[0].properties,
48
49
  labels=nodes[0].labels,
49
- source_url=edit_url,
50
50
  site_url=site_url,
51
51
  confluence_status=nodes[0].confluence_status,
52
52
  )
53
53
  return (updated,) + nodes[1:]
54
54
 
55
- # No existing front matter — create a minimal one just for the link rows.
55
+ # No existing front matter — create a minimal one just for the link row.
56
56
  minimal = FrontMatter(
57
57
  title=None,
58
58
  subtitle=None,
59
59
  properties=(),
60
60
  labels=(),
61
- source_url=edit_url,
62
61
  site_url=site_url,
63
62
  )
64
63
  return (minimal,) + nodes
@@ -0,0 +1,69 @@
1
+ """Source-footer transform.
2
+
3
+ Builds a :class:`~ir.nodes.SourceFooter` node from a VCS edit URL and the
4
+ absolute path of the source file. The node is appended to the IR node list
5
+ by the publish pipeline so it renders as a footer panel on each Confluence page.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+
12
+ from mkdocs_to_confluence.ir.nodes import SourceFooter
13
+
14
+
15
+ def _derive_history_url(edit_url: str) -> str | None:
16
+ """Derive a commit-history URL from a VCS edit URL.
17
+
18
+ Supports GitHub (``/edit/``) and GitLab (``/-/edit/``).
19
+ Returns ``None`` for any other URL shape.
20
+ """
21
+ if "/-/edit/" in edit_url:
22
+ return edit_url.replace("/-/edit/", "/-/commits/", 1)
23
+ if "/edit/" in edit_url:
24
+ return edit_url.replace("/edit/", "/commits/", 1)
25
+ return None
26
+
27
+
28
+ def _last_commit(abs_path: str) -> str | None:
29
+ """Return a human-readable last-commit summary for *abs_path*.
30
+
31
+ Runs ``git log -1 --format=... --date=relative`` on the file.
32
+ Returns ``None`` when git is unavailable, the path is untracked, or the
33
+ command fails for any reason.
34
+ """
35
+ try:
36
+ result = subprocess.run(
37
+ [
38
+ "git", "log", "-1",
39
+ "--format=%h \u00b7 %s \u00b7 %an \u00b7 %ad",
40
+ "--date=relative",
41
+ "--",
42
+ abs_path,
43
+ ],
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=5,
47
+ )
48
+ output = result.stdout.strip()
49
+ return output if output else None
50
+ except Exception: # noqa: BLE001
51
+ return None
52
+
53
+
54
+ def build_source_footer(edit_url: str, abs_path: str) -> SourceFooter:
55
+ """Build a :class:`SourceFooter` for the given *edit_url* and source file.
56
+
57
+ Parameters
58
+ ----------
59
+ edit_url:
60
+ Full URL to edit the source file (e.g. GitHub edit link).
61
+ abs_path:
62
+ Absolute filesystem path to the source Markdown file. Used to
63
+ query ``git log`` for the last-commit summary.
64
+ """
65
+ return SourceFooter(
66
+ edit_url=edit_url,
67
+ history_url=_derive_history_url(edit_url),
68
+ last_commit=_last_commit(abs_path),
69
+ )
@@ -1,4 +1,4 @@
1
- """Tests for the edit-link / source-url attachment transform."""
1
+ """Tests for the edit-link / site-url attachment transform."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,31 +8,29 @@ from mkdocs_to_confluence.transforms.editlink import attach_source_url
8
8
  # ── attach_source_url ─────────────────────────────────────────────────────────
9
9
 
10
10
 
11
- def test_source_url_added_to_existing_front_matter():
12
- """source_url is set on an existing FrontMatter node."""
11
+ def test_source_url_ignored_when_only_edit_url_provided():
12
+ """edit_url is now ignored; attach_source_url with no site_url returns nodes unchanged."""
13
13
  fm = FrontMatter(title="My Page", subtitle=None, properties=(), labels=())
14
14
  original = (fm, Paragraph(children=(TextNode(text="Hello"),)))
15
15
  result = attach_source_url(original, "https://github.com/org/repo/edit/main/docs/index.md")
16
- assert isinstance(result[0], FrontMatter)
17
- assert result[0].source_url == "https://github.com/org/repo/edit/main/docs/index.md"
18
- assert result[1] is original[1]
16
+ assert result is original
19
17
 
20
18
 
21
19
  def test_existing_front_matter_fields_preserved():
22
- """Attaching source_url does not lose other FrontMatter fields."""
20
+ """Attaching site_url does not lose other FrontMatter fields."""
23
21
  fm = FrontMatter(
24
22
  title="My Page",
25
23
  subtitle="A subtitle",
26
24
  properties=(("Version", "1.0"),),
27
25
  labels=("arch",),
28
26
  )
29
- result = attach_source_url((fm,), "https://example.com/edit")
27
+ result = attach_source_url((fm,), "", site_url="https://site.io/page/")
30
28
  updated: FrontMatter = result[0] # type: ignore[assignment]
31
29
  assert updated.title == "My Page"
32
30
  assert updated.subtitle == "A subtitle"
33
31
  assert updated.properties == (("Version", "1.0"),)
34
32
  assert updated.labels == ("arch",)
35
- assert updated.source_url == "https://example.com/edit"
33
+ assert updated.site_url == "https://site.io/page/"
36
34
 
37
35
 
38
36
  def test_confluence_status_preserved_by_attach_source_url():
@@ -44,7 +42,7 @@ def test_confluence_status_preserved_by_attach_source_url():
44
42
  labels=("arch",),
45
43
  confluence_status="in-progress",
46
44
  )
47
- result = attach_source_url((fm,), "https://example.com/edit")
45
+ result = attach_source_url((fm,), "", site_url="https://site.io/page/")
48
46
  updated: FrontMatter = result[0] # type: ignore[assignment]
49
47
  assert updated.confluence_status == "in-progress"
50
48
 
@@ -52,20 +50,27 @@ def test_confluence_status_preserved_by_attach_source_url():
52
50
  def test_minimal_front_matter_created_when_none_present():
53
51
  """A minimal FrontMatter is prepended when the page has no front matter."""
54
52
  body = (Paragraph(children=(TextNode(text="Content"),)),)
55
- result = attach_source_url(body, "https://example.com/edit")
53
+ result = attach_source_url(body, "", site_url="https://site.io/page/")
56
54
  assert len(result) == 2
57
55
  assert isinstance(result[0], FrontMatter)
58
- assert result[0].source_url == "https://example.com/edit"
56
+ assert result[0].site_url == "https://site.io/page/"
59
57
  assert result[0].title is None
60
58
  assert result[0].properties == ()
61
59
  assert result[1] is body[0]
62
60
 
63
61
 
64
62
  def test_empty_nodes_gets_minimal_front_matter():
65
- result = attach_source_url((), "https://example.com/edit")
63
+ result = attach_source_url((), "", site_url="https://site.io/")
66
64
  assert len(result) == 1
67
65
  assert isinstance(result[0], FrontMatter)
68
- assert result[0].source_url == "https://example.com/edit"
66
+ assert result[0].site_url == "https://site.io/"
67
+
68
+
69
+ def test_no_site_url_returns_nodes_unchanged():
70
+ """When site_url is absent, nodes are returned as-is (no FrontMatter injected)."""
71
+ body = (Paragraph(children=(TextNode(text="Content"),)),)
72
+ result = attach_source_url(body, "")
73
+ assert result is body
69
74
 
70
75
 
71
76
  # ── page_edit_url ─────────────────────────────────────────────────────────────
@@ -258,5 +263,4 @@ def test_site_url_attached_by_transform():
258
263
  (fm,), "https://github.com/edit", site_url="https://site.io/page/"
259
264
  )
260
265
  assert isinstance(result[0], FrontMatter)
261
- assert result[0].source_url == "https://github.com/edit"
262
266
  assert result[0].site_url == "https://site.io/page/"
@@ -0,0 +1,119 @@
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from mkdocs_to_confluence.emitter.xhtml import _source_link_label, emit
5
+ from mkdocs_to_confluence.emitter.xhtml import emit
6
6
  from mkdocs_to_confluence.ir.nodes import FrontMatter
7
7
  from mkdocs_to_confluence.preprocess.frontmatter import extract_front_matter
8
8
 
@@ -194,70 +194,3 @@ def test_special_chars_escaped_in_properties():
194
194
  assert "&amp;" in xhtml
195
195
  assert "&lt;" in xhtml
196
196
  assert "O&#x27;Brien" in xhtml or "O&apos;Brien" in xhtml or "O&#39;" in xhtml or "O'" not in xhtml
197
-
198
-
199
- def test_source_url_renders_as_link_row():
200
- fm = FrontMatter(
201
- title=None,
202
- subtitle=None,
203
- properties=(),
204
- labels=(),
205
- source_url="https://github.com/org/repo/edit/main/docs/index.md",
206
- )
207
- xhtml = emit((fm,))
208
- assert 'ac:name="details"' in xhtml
209
- assert '<th>Source</th>' in xhtml
210
- assert 'href="https://github.com/org/repo/edit/main/docs/index.md"' in xhtml
211
-
212
-
213
- def test_source_url_appears_after_other_properties():
214
- fm = FrontMatter(
215
- title=None,
216
- subtitle=None,
217
- properties=(("Version", "2.0"),),
218
- labels=(),
219
- source_url="https://example.com/edit",
220
- )
221
- xhtml = emit((fm,))
222
- version_pos = xhtml.index("Version")
223
- source_pos = xhtml.index("Source")
224
- assert source_pos > version_pos, "Source row should appear after other properties"
225
-
226
-
227
- def test_source_url_alone_still_emits_details_macro():
228
- """A page with no front matter but a source_url should show the table."""
229
- fm = FrontMatter(title=None, subtitle=None, properties=(), labels=(), source_url="https://example.com/edit")
230
- xhtml = emit((fm,))
231
- assert 'ac:name="details"' in xhtml
232
- assert '<th>Source</th>' in xhtml
233
-
234
-
235
- # --- _source_link_label ---
236
-
237
- def test_source_link_label_github():
238
- assert _source_link_label("https://github.com/org/repo/edit/main/docs/page.md") == "Edit in GitHub ↗"
239
-
240
-
241
- def test_source_link_label_gitlab_dot_com():
242
- assert _source_link_label("https://gitlab.com/org/repo/-/edit/main/docs/page.md") == "Edit in GitLab ↗"
243
-
244
-
245
- def test_source_link_label_self_hosted_gitlab():
246
- assert _source_link_label("https://gitlab.mycompany.com/org/repo/-/edit/main/docs/page.md") == "Edit in GitLab ↗"
247
-
248
-
249
- def test_source_link_label_bitbucket():
250
- assert _source_link_label("https://bitbucket.org/org/repo/src/main/docs/page.md") == "Edit in Bitbucket ↗"
251
-
252
-
253
- def test_source_link_label_unknown_host_fallback():
254
- assert _source_link_label("https://example.com/edit") == "Edit source ↗"
255
-
256
-
257
- def test_source_link_label_renders_in_xhtml():
258
- fm = FrontMatter(
259
- title=None, subtitle=None, properties=(), labels=(),
260
- source_url="https://github.com/org/repo/edit/main/docs/index.md",
261
- )
262
- xhtml = emit((fm,))
263
- assert "Edit in GitHub" in xhtml