mkdocs2confluence 0.9.0__tar.gz → 0.9.2__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.2}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/pyproject.toml +2 -1
  3. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs2confluence.egg-info/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/cli.py +4 -4
  5. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/emitter/xhtml.py +24 -5
  6. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/ir/nodes.py +13 -7
  7. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/publisher/client.py +2 -1
  8. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/publisher/pipeline.py +16 -8
  9. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/footer.py +42 -6
  10. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_cli.py +2 -2
  11. mkdocs2confluence-0.9.2/tests/test_footer.py +217 -0
  12. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_publish_pipeline.py +8 -8
  13. mkdocs2confluence-0.9.0/tests/test_footer.py +0 -119
  14. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/LICENSE +0 -0
  15. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/README.md +0 -0
  16. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/setup.cfg +0 -0
  17. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  18. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  19. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  20. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  21. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  22. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/__init__.py +0 -0
  23. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  24. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  25. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/ir/document.py +0 -0
  26. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  27. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  28. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/loader/config.py +0 -0
  29. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  30. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  31. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/loader/page.py +0 -0
  32. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  33. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  34. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  35. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  36. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  37. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  38. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  39. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  40. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  41. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  42. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  43. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  44. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  45. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preview/render.py +0 -0
  46. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/preview/server.py +0 -0
  47. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  48. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  49. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  50. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/sync/command.py +0 -0
  51. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  52. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/sync/github.py +0 -0
  53. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  54. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/sync/state.py +0 -0
  55. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  56. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  57. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  58. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  59. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  60. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  61. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  62. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_abbrevs.py +0 -0
  63. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_editlink.py +0 -0
  64. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_emitter.py +0 -0
  65. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_extra_css.py +0 -0
  66. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_frontmatter.py +0 -0
  67. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_icons.py +0 -0
  68. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_images.py +0 -0
  69. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_internallinks.py +0 -0
  70. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_ir.py +0 -0
  71. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_linkdefs.py +0 -0
  72. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_loader.py +0 -0
  73. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_mermaid.py +0 -0
  74. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_page_loader.py +0 -0
  75. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_parser.py +0 -0
  76. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_pdf.py +0 -0
  77. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_preprocess.py +0 -0
  78. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_preview.py +0 -0
  79. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_publish_client.py +0 -0
  80. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_publish_config.py +0 -0
  81. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_server.py +0 -0
  82. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_sync_anchoring.py +0 -0
  83. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_sync_command.py +0 -0
  84. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_sync_comments.py +0 -0
  85. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_sync_github.py +0 -0
  86. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/tests/test_sync_state.py +0 -0
  87. {mkdocs2confluence-0.9.0 → mkdocs2confluence-0.9.2}/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.2
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.2"
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" }
@@ -73,6 +73,7 @@ select = ["E", "F", "I"]
73
73
 
74
74
  [tool.mypy]
75
75
  strict = true
76
+ extra_checks = true
76
77
  python_version = "3.12"
77
78
 
78
79
  [[tool.mypy.overrides]]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.9.0
3
+ Version: 0.9.2
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
@@ -359,7 +359,7 @@ def _cmd_preview(args: argparse.Namespace) -> None:
359
359
  for node in pages:
360
360
  html_name = page_link_map.get(node.title, f"{Path(node.docs_path or node.title).stem}.html")
361
361
  try:
362
- xhtml, _a, _l, _s = compile_page(node, config, link_map, quiet=args.quiet)
362
+ xhtml, _a, _l, _s, _vm = compile_page(node, config, link_map, quiet=args.quiet)
363
363
  except PageLoadError as exc:
364
364
  print(f" warning: skipping '{node.title}': {exc}", file=sys.stderr)
365
365
  continue
@@ -417,7 +417,7 @@ def _cmd_preview(args: argparse.Namespace) -> None:
417
417
 
418
418
  def _build_page() -> None:
419
419
  try:
420
- xhtml, _a, _l, _s = compile_page(page_node, config, link_map, quiet=True)
420
+ xhtml, _a, _l, _s, _vm = compile_page(page_node, config, link_map, quiet=True)
421
421
  except PageLoadError as exc:
422
422
  print(f" warning: {exc}", file=sys.stderr)
423
423
  return
@@ -441,7 +441,7 @@ def _cmd_preview(args: argparse.Namespace) -> None:
441
441
  return
442
442
 
443
443
  try:
444
- xhtml, _attachments, _labels, _status = compile_page(page_node, config, link_map, quiet=args.quiet)
444
+ xhtml, _attachments, _labels, _status, _vm = compile_page(page_node, config, link_map, quiet=args.quiet)
445
445
  except PageLoadError as exc:
446
446
  print(f"error: {exc}", file=sys.stderr)
447
447
  sys.exit(1)
@@ -656,7 +656,7 @@ def _cmd_pdf(args: argparse.Namespace) -> None:
656
656
  chapters: list[tuple[str, str]] = []
657
657
  for node in pages:
658
658
  try:
659
- xhtml, _a, _l, _s = compile_page(node, config, link_map, quiet=args.quiet)
659
+ xhtml, _a, _l, _s, _vm = compile_page(node, config, link_map, quiet=args.quiet)
660
660
  except PageLoadError as exc:
661
661
  print(f" warning: skipping '{node.title}': {exc}", file=sys.stderr)
662
662
  continue
@@ -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)
@@ -255,6 +255,7 @@ class ConfluenceClient:
255
255
  version: int,
256
256
  *,
257
257
  parent_id: str | None = None,
258
+ version_message: str | None = None,
258
259
  ) -> dict[str, Any]:
259
260
  """Update an existing page to a new version and return the page dict.
260
261
 
@@ -273,7 +274,7 @@ class ConfluenceClient:
273
274
  },
274
275
  "version": {
275
276
  "number": version,
276
- "message": "Updated by mk2conf",
277
+ "message": version_message or "Published by mk2conf",
277
278
  "minorEdit": True,
278
279
  },
279
280
  }
@@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Literal
24
24
  import yaml
25
25
 
26
26
  from mkdocs_to_confluence.emitter.xhtml import emit
27
- from mkdocs_to_confluence.ir.nodes import FrontMatter
27
+ from mkdocs_to_confluence.ir.nodes import FrontMatter, SourceFooter
28
28
  from mkdocs_to_confluence.loader.config import ConfluenceConfig, MkDocsConfig
29
29
  from mkdocs_to_confluence.loader.nav import NavNode
30
30
  from mkdocs_to_confluence.loader.page import PageLoadError, load_page
@@ -75,6 +75,7 @@ class PageAction:
75
75
  attachments: list[Path] = field(default_factory=list)
76
76
  labels: tuple[str, ...] = field(default_factory=tuple)
77
77
  confluence_status: str | None = None
78
+ version_message: str | None = None # git commit message for Confluence version history
78
79
  is_folder: bool = False # True when this action creates a Confluence folder
79
80
  parent_is_folder: bool = False # True when the parent content is a folder
80
81
  # Set after execution:
@@ -139,7 +140,7 @@ def compile_page(
139
140
  link_map: dict[str, str] | None = None,
140
141
  *,
141
142
  quiet: bool = False,
142
- ) -> tuple[str, list[Path], tuple[str, ...], str | None]:
143
+ ) -> tuple[str, list[Path], tuple[str, ...], str | None, str | None]:
143
144
  """Run the full compile pipeline for one page.
144
145
 
145
146
  Returns
@@ -148,7 +149,7 @@ def compile_page(
148
149
  ``(xhtml_string, attachment_paths, labels, confluence_status)``
149
150
  """
150
151
  if node.source_path is None:
151
- return "", [], (), None
152
+ return "", [], (), None, None
152
153
 
153
154
  raw = load_page(node)
154
155
 
@@ -195,17 +196,19 @@ def compile_page(
195
196
  footer = build_source_footer(edit_url, abs_path)
196
197
  ir_nodes = ir_nodes + (footer,)
197
198
 
198
- # Extract labels and confluence_status from FrontMatter node.
199
+ # Extract labels, confluence_status, and version_message from IR nodes.
199
200
  labels: tuple[str, ...] = ()
200
201
  confluence_status: str | None = None
202
+ version_message: str | None = None
201
203
  for node_item in ir_nodes:
202
204
  if isinstance(node_item, FrontMatter):
203
205
  labels = node_item.labels
204
206
  confluence_status = node_item.confluence_status
205
- break
207
+ if isinstance(node_item, SourceFooter) and node_item.commit_sha and node_item.commit_summary:
208
+ version_message = f"{node_item.commit_sha}: {node_item.commit_summary}"
206
209
 
207
210
  xhtml = emit(ir_nodes)
208
- return xhtml, attachments, labels, confluence_status
211
+ return xhtml, attachments, labels, confluence_status, version_message
209
212
 
210
213
 
211
214
  def _xhtml_hash(xhtml: str) -> str:
@@ -279,7 +282,7 @@ def _plan_nodes(
279
282
  if not quiet:
280
283
  print(f" compiling '{clean_title}' (section index)")
281
284
  try:
282
- xhtml, attachments, labels, confluence_status = compile_page(
285
+ xhtml, attachments, labels, confluence_status, version_message = compile_page(
283
286
  index_child, config, link_map, quiet=quiet
284
287
  )
285
288
  existing = client.find_page(space_id, clean_title)
@@ -312,6 +315,7 @@ def _plan_nodes(
312
315
  attachments=attachments,
313
316
  labels=labels,
314
317
  confluence_status=confluence_status,
318
+ version_message=version_message,
315
319
  page_id=str(existing["id"]) if existing is not None else None,
316
320
  version=(
317
321
  existing["version"]["number"] if existing is not None else None
@@ -379,7 +383,9 @@ def _plan_nodes(
379
383
  if not quiet:
380
384
  print(f" compiling '{clean_title}'")
381
385
  try:
382
- xhtml, attachments, labels, confluence_status = compile_page(node, config, link_map, quiet=quiet)
386
+ xhtml, attachments, labels, confluence_status, version_message = compile_page(
387
+ node, config, link_map, quiet=quiet
388
+ )
383
389
  except (PageLoadError, OSError) as exc:
384
390
  if not quiet:
385
391
  print(f" skipping '{clean_title}' (error: {exc})")
@@ -419,6 +425,7 @@ def _plan_nodes(
419
425
  attachments=attachments,
420
426
  labels=labels,
421
427
  confluence_status=confluence_status,
428
+ version_message=version_message,
422
429
  page_id=str(existing["id"]) if existing is not None else None,
423
430
  version=(
424
431
  existing["version"]["number"] if existing is not None else None
@@ -588,6 +595,7 @@ def _execute_page_action(
588
595
  action.xhtml or "",
589
596
  action.version + 1,
590
597
  parent_id=action.parent_id,
598
+ version_message=action.version_message,
591
599
  )
592
600
  report.updated += 1
593
601
  except ConfluenceError as upd_exc:
@@ -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
  )
@@ -175,7 +175,7 @@ class TestQuietOutputBehavior:
175
175
  yml = _minimal_config(tmp_path)
176
176
  (tmp_path / "docs" / "index.md").write_text("# Home\n\nHello.", encoding="utf-8")
177
177
 
178
- mock_compile = MagicMock(return_value=("<p>Hello</p>", [], (), None))
178
+ mock_compile = MagicMock(return_value=("<p>Hello</p>", [], (), None, None))
179
179
  with patch("mkdocs_to_confluence.cli.compile_page", mock_compile), \
180
180
  patch("sys.stdout.isatty", return_value=False):
181
181
  flags = ["--quiet"] if quiet else []
@@ -223,7 +223,7 @@ class TestWatchFlag:
223
223
  yml = _minimal_config(tmp_path)
224
224
  (tmp_path / "docs" / "index.md").write_text("# Home\n", encoding="utf-8")
225
225
 
226
- mock_compile = MagicMock(return_value=("<p>Hello</p>", [], (), None))
226
+ mock_compile = MagicMock(return_value=("<p>Hello</p>", [], (), None, None))
227
227
  mock_render = MagicMock(return_value="<html>preview</html>")
228
228
 
229
229
  with patch("mkdocs_to_confluence.cli.compile_page", mock_compile), \
@@ -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
@@ -53,7 +53,7 @@ def test_compile_page_returns_xhtml(tmp_path: Path) -> None:
53
53
 
54
54
  node = _page_node("Index", md)
55
55
  config = _make_config(docs)
56
- xhtml, attachments, labels, _ = compile_page(node, config)
56
+ xhtml, attachments, labels, _, _ = compile_page(node, config)
57
57
 
58
58
  assert "<h1>" in xhtml or "Hello" in xhtml
59
59
  assert attachments == []
@@ -68,7 +68,7 @@ def test_compile_page_with_ready_false_still_compiles(tmp_path: Path) -> None:
68
68
 
69
69
  node = _page_node("Draft", md)
70
70
  config = _make_config(docs)
71
- xhtml, attachments, labels, _ = compile_page(node, config)
71
+ xhtml, attachments, labels, _, _ = compile_page(node, config)
72
72
  # Still compiles fine; plan_publish is the gatekeeper
73
73
  assert isinstance(xhtml, str)
74
74
 
@@ -78,7 +78,7 @@ def test_compile_page_with_source_path_none_returns_empty(tmp_path: Path) -> Non
78
78
  docs.mkdir()
79
79
  node = NavNode(title="Missing", docs_path="missing.md", source_path=None, level=0)
80
80
  config = _make_config(docs)
81
- xhtml, attachments, labels, _ = compile_page(node, config)
81
+ xhtml, attachments, labels, _, _ = compile_page(node, config)
82
82
  assert xhtml == ""
83
83
  assert attachments == []
84
84
  assert labels == ()
@@ -179,7 +179,7 @@ def test_plan_publish_skips_when_content_unchanged(tmp_path: Path) -> None:
179
179
 
180
180
  # Compile once to get the real hash
181
181
  from mkdocs_to_confluence.publisher.pipeline import compile_page
182
- xhtml, _, _, _ = compile_page(node, config)
182
+ xhtml, _, _, _, _ = compile_page(node, config)
183
183
  stored_hash = _xhtml_hash(xhtml)
184
184
 
185
185
  existing_page = {"id": "77", "version": {"number": 2}}
@@ -1672,7 +1672,7 @@ class TestExecutePublishHelpers:
1672
1672
 
1673
1673
  assert report.updated == 1
1674
1674
  client.update_page.assert_called_once_with(
1675
- "existing-9", "P", "<p/>", 4, parent_id="ROOT"
1675
+ "existing-9", "P", "<p/>", 4, parent_id="ROOT", version_message=None
1676
1676
  )
1677
1677
  client.create_page.assert_not_called()
1678
1678
 
@@ -2036,7 +2036,7 @@ def test_compile_page_returns_confluence_status(tmp_path: Path) -> None:
2036
2036
 
2037
2037
  node = _page_node("My Page", md)
2038
2038
  config = _make_config(docs)
2039
- _, _, _, confluence_status = compile_page(node, config)
2039
+ _, _, _, confluence_status, _ = compile_page(node, config)
2040
2040
 
2041
2041
  assert confluence_status == "in-progress"
2042
2042
 
@@ -2060,7 +2060,7 @@ def test_compile_page_returns_confluence_status_with_repo_url(tmp_path: Path) ->
2060
2060
  edit_uri="edit/main/docs/",
2061
2061
  nav=None,
2062
2062
  )
2063
- _, _, _, confluence_status = compile_page(node, config)
2063
+ _, _, _, confluence_status, _ = compile_page(node, config)
2064
2064
 
2065
2065
  assert confluence_status == "in-progress"
2066
2066
 
@@ -2098,7 +2098,7 @@ def test_plan_publish_sets_confluence_status_on_skip(tmp_path: Path) -> None:
2098
2098
  config = _make_config(docs)
2099
2099
  conf_config = _make_conf_config()
2100
2100
 
2101
- xhtml, _, _, _ = compile_page(node, config)
2101
+ xhtml, _, _, _, _ = compile_page(node, config)
2102
2102
  stored_hash = _xhtml_hash(xhtml)
2103
2103
 
2104
2104
  existing_page = {"id": "77", "version": {"number": 2}}
@@ -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