mkdocs2confluence 0.18.0__tar.gz → 0.19.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 (110) hide show
  1. {mkdocs2confluence-0.18.0/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.19.0}/PKG-INFO +3 -3
  2. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/pyproject.toml +20 -5
  3. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0/src/mkdocs2confluence.egg-info}/PKG-INFO +3 -3
  4. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs2confluence.egg-info/requires.txt +2 -2
  5. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/cli.py +1 -1
  6. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/loader/config.py +6 -2
  7. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/parser/markdown.py +117 -23
  8. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/includes.py +1 -1
  9. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/publisher/client.py +4 -1
  10. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/skill_installer.py +2 -2
  11. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/_kroki.py +4 -1
  12. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/mermaid.py +1 -1
  13. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_parser.py +208 -0
  14. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_skill_installer.py +2 -0
  15. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/LICENSE +0 -0
  16. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/README.md +0 -0
  17. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/setup.cfg +0 -0
  18. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  19. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  20. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  21. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  22. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/__init__.py +0 -0
  23. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/compiler/__init__.py +0 -0
  24. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/compiler/models.py +0 -0
  25. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/compiler/page.py +0 -0
  26. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  27. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/emitter/xhtml.py +0 -0
  28. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  29. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  30. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  31. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  32. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  33. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  34. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/loader/page.py +0 -0
  35. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  36. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  37. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  38. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  39. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  40. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  41. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/captions.py +0 -0
  42. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  43. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  44. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  45. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  46. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  47. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preview/render.py +0 -0
  48. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/preview/server.py +0 -0
  49. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  50. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/publisher/changelog.py +0 -0
  51. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/publisher/executor.py +0 -0
  52. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/publisher/http_retry.py +0 -0
  53. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/publisher/models.py +0 -0
  54. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/publisher/planner.py +0 -0
  55. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/skills/mkdocs-changelog/SKILL.md +0 -0
  56. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/skills/mkdocs-changelog/scripts/changelog_data.py +0 -0
  57. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  58. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  59. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/sync/command.py +0 -0
  60. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  61. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/sync/github.py +0 -0
  62. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  63. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/sync/state.py +0 -0
  64. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  65. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  66. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/admonition_titles.py +0 -0
  67. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  68. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/attachment_previews.py +0 -0
  69. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/captions.py +0 -0
  70. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  71. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/footer.py +0 -0
  72. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  73. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  74. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/src/mkdocs_to_confluence/transforms/plantuml.py +0 -0
  75. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_abbrevs.py +0 -0
  76. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_admonition_titles.py +0 -0
  77. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_attachment_previews.py +0 -0
  78. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_captions.py +0 -0
  79. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_changelog_config.py +0 -0
  80. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_changelog_publish.py +0 -0
  81. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_children_macro.py +0 -0
  82. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_cli.py +0 -0
  83. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_editlink.py +0 -0
  84. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_emitter.py +0 -0
  85. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_exclude_properties_config.py +0 -0
  86. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_extra_css.py +0 -0
  87. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_footer.py +0 -0
  88. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_frontmatter.py +0 -0
  89. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_icons.py +0 -0
  90. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_images.py +0 -0
  91. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_internallinks.py +0 -0
  92. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_ir.py +0 -0
  93. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_linkdefs.py +0 -0
  94. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_loader.py +0 -0
  95. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_mermaid.py +0 -0
  96. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_page_loader.py +0 -0
  97. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_pdf.py +0 -0
  98. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_plantuml.py +0 -0
  99. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_preprocess.py +0 -0
  100. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_preview.py +0 -0
  101. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_publish_client.py +0 -0
  102. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_publish_config.py +0 -0
  103. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_publish_pipeline.py +0 -0
  104. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_server.py +0 -0
  105. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_sync_anchoring.py +0 -0
  106. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_sync_command.py +0 -0
  107. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_sync_comments.py +0 -0
  108. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_sync_github.py +0 -0
  109. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_sync_state.py +0 -0
  110. {mkdocs2confluence-0.18.0 → mkdocs2confluence-0.19.0}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.18.0
3
+ Version: 0.19.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
@@ -28,14 +28,14 @@ Requires-Dist: httpx>=0.28
28
28
  Requires-Dist: idna>=3.18
29
29
  Requires-Dist: tinycss2>=1.5.1
30
30
  Provides-Extra: pdf
31
- Requires-Dist: weasyprint>=69.0; extra == "pdf"
31
+ Requires-Dist: weasyprint>=70.0; extra == "pdf"
32
32
  Provides-Extra: dev
33
33
  Requires-Dist: pytest; extra == "dev"
34
34
  Requires-Dist: pytest-cov; extra == "dev"
35
35
  Requires-Dist: ruff; extra == "dev"
36
36
  Requires-Dist: mypy; extra == "dev"
37
37
  Requires-Dist: types-PyYAML; extra == "dev"
38
- Requires-Dist: bandit; extra == "dev"
38
+ Requires-Dist: bandit[sarif]; extra == "dev"
39
39
  Requires-Dist: build; extra == "dev"
40
40
  Requires-Dist: pre-commit; extra == "dev"
41
41
  Dynamic: license-file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.18.0"
3
+ version = "0.19.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" }
@@ -44,7 +44,7 @@ Changelog = "https://github.com/jeckyl2010/mkdocs2confluence/releases"
44
44
 
45
45
  [project.optional-dependencies]
46
46
  pdf = [
47
- "weasyprint>=69.0",
47
+ "weasyprint>=70.0",
48
48
  ]
49
49
  dev = [
50
50
  "pytest",
@@ -52,7 +52,7 @@ dev = [
52
52
  "ruff",
53
53
  "mypy",
54
54
  "types-PyYAML",
55
- "bandit",
55
+ "bandit[sarif]",
56
56
  "build",
57
57
  "pre-commit",
58
58
  ]
@@ -74,7 +74,22 @@ where = ["src"]
74
74
  line-length = 120
75
75
 
76
76
  [tool.ruff.lint]
77
- select = ["E", "F", "I"]
77
+ # S = flake8-bandit (security), B = flake8-bugbear (likely bugs).
78
+ select = ["E", "F", "I", "S", "B"]
79
+ ignore = [
80
+ # Deliberate throughout: best-effort side effects (content hashes, managed-page
81
+ # stamps) whose failure is self-healing on the next run. Each site says so.
82
+ "S110", # try-except-pass
83
+ # Fixed argument lists only — `git log` in the footer transform and the macOS
84
+ # DYLD self-re-exec in cli.py. No user input reaches a subprocess.
85
+ "S603", "S606", "S607",
86
+ # Retry jitter in http_retry.py, not a security context.
87
+ "S311",
88
+ ]
89
+
90
+ [tool.ruff.lint.per-file-ignores]
91
+ # Tests assert freely and use throwaway credentials in fixtures.
92
+ "tests/**" = ["S101", "S105", "S106", "S107"]
78
93
 
79
94
  [tool.mypy]
80
95
  strict = true
@@ -104,7 +119,7 @@ exclude_lines = [
104
119
  ]
105
120
 
106
121
  # Architecture boundaries — enforces the compile pipeline stage separation
107
- # documented in .github/copilot-instructions.md. Higher layers may import lower
122
+ # documented in AGENTS.md. Higher layers may import lower
108
123
  # ones; siblings joined by "|" must stay independent of each other.
109
124
  [tool.importlinter]
110
125
  root_package = "mkdocs_to_confluence"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.18.0
3
+ Version: 0.19.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
@@ -28,14 +28,14 @@ Requires-Dist: httpx>=0.28
28
28
  Requires-Dist: idna>=3.18
29
29
  Requires-Dist: tinycss2>=1.5.1
30
30
  Provides-Extra: pdf
31
- Requires-Dist: weasyprint>=69.0; extra == "pdf"
31
+ Requires-Dist: weasyprint>=70.0; extra == "pdf"
32
32
  Provides-Extra: dev
33
33
  Requires-Dist: pytest; extra == "dev"
34
34
  Requires-Dist: pytest-cov; extra == "dev"
35
35
  Requires-Dist: ruff; extra == "dev"
36
36
  Requires-Dist: mypy; extra == "dev"
37
37
  Requires-Dist: types-PyYAML; extra == "dev"
38
- Requires-Dist: bandit; extra == "dev"
38
+ Requires-Dist: bandit[sarif]; extra == "dev"
39
39
  Requires-Dist: build; extra == "dev"
40
40
  Requires-Dist: pre-commit; extra == "dev"
41
41
  Dynamic: license-file
@@ -9,9 +9,9 @@ pytest-cov
9
9
  ruff
10
10
  mypy
11
11
  types-PyYAML
12
- bandit
12
+ bandit[sarif]
13
13
  build
14
14
  pre-commit
15
15
 
16
16
  [pdf]
17
- weasyprint>=69.0
17
+ weasyprint>=70.0
@@ -274,7 +274,7 @@ def _build_parser() -> argparse.ArgumentParser:
274
274
  "Detected targets (all installed when no --tool given):\n"
275
275
  " hermes ~/.hermes/skills/tooling/mkdocs-changelog/SKILL.md\n"
276
276
  " github-skills .github/skills/tooling/mkdocs-changelog/SKILL.md\n"
277
- " claude .claude/commands/changelog.md (frontmatter stripped)\n"
277
+ " claude .claude/commands/mk2conf-changelog.md (frontmatter stripped)\n"
278
278
  " copilot .github/instructions/mk2conf-changelog.instructions.md\n"
279
279
  " cursor .cursor/rules/mk2conf-changelog.mdc\n"
280
280
  "\n"
@@ -154,7 +154,11 @@ def load_config(mkdocs_yml: Path) -> MkDocsConfig:
154
154
  raise FileNotFoundError(f"mkdocs.yml not found: {mkdocs_yml}")
155
155
 
156
156
  with mkdocs_yml.open(encoding="utf-8") as fh:
157
- raw: object = yaml.load(fh, Loader=_make_env_loader()) # nosec B506
157
+ # B506 is suppressed below because _make_env_loader() returns a
158
+ # yaml.SafeLoader subclass — this is a safe load. Bandit cannot resolve the
159
+ # loader through the function call, so it assumes the worst. Removing the
160
+ # suppression reintroduces a medium-severity finding that fails CI.
161
+ raw: object = yaml.load(fh, Loader=_make_env_loader()) # noqa: S506 # nosec B506
158
162
 
159
163
  if not isinstance(raw, dict):
160
164
  raise ConfigError("mkdocs.yml must be a YAML mapping at the top level.")
@@ -276,7 +280,7 @@ def load_config(mkdocs_yml: Path) -> MkDocsConfig:
276
280
  raise ConfigError(
277
281
  f"mkdocs.yml: 'confluence.changelog' path {cl_str!r} "
278
282
  "escapes docs_dir. The path must be relative to the docs directory."
279
- )
283
+ ) from None
280
284
  changelog_file = cl_str
281
285
 
282
286
  # exclude_properties (optional) — raw front matter keys to omit from
@@ -256,7 +256,7 @@ _FENCE_OPEN_RE = re.compile(r"^(?P<fence>`{3,}|~{3,})(?P<info>.*)$")
256
256
 
257
257
  # Matches an admonition opener: !!! kind / !!! kind "title" / ??? kind / ???+ kind
258
258
  _ADMONITION_RE = re.compile(
259
- r"""^(?P<marker>!{3}|\?{3}\+?)\s+(?P<kind>\w+)(?:\s+(?P<q>["'])(?P<title>.*?)(?P=q))?$"""
259
+ r"""^[ ]{0,3}(?P<marker>!{3}|\?{3}\+?)\s+(?P<kind>\w+)(?:\s+(?P<q>["'])(?P<title>.*?)(?P=q))?\s*$"""
260
260
  )
261
261
 
262
262
  # Matches a Material for MkDocs content tab opener: === "Label"
@@ -296,39 +296,129 @@ _FOOTNOTE_REF_RE = re.compile(r'^\[\^(?P<label>[^\]]+)\]')
296
296
  # Matches a definition list definition line: : text
297
297
  _DEFLIST_DEF_RE = re.compile(r'^:\s+(?P<text>.+)$')
298
298
 
299
- # Matches <div class="grid cards" markdown> (with or without quotes around class value).
300
- _GRID_CARD_DIV_RE = re.compile(
301
- r'^\s*<div\b[^>]*\bclass=["\'][^"\']*\bgrid\s+cards\b[^"\']*["\'][^>]*>\s*$',
299
+ # Matches any <div ... class="..."> opener, capturing the class list. Material has
300
+ # two grid flavours: ``grid cards`` (each list item / admonition is a card) and a
301
+ # plain ``grid`` (each top-level block is one grid item).
302
+ _DIV_CLASS_RE = re.compile(
303
+ r'^\s*<div\b[^>]*\bclass=["\'](?P<classes>[^"\']*)["\'][^>]*>\s*$',
302
304
  re.IGNORECASE,
303
305
  )
306
+ # Any <div> opener, used only for nesting depth so an inner div cannot close the grid.
307
+ _ANY_DIV_OPEN_RE = re.compile(r'^\s*<div\b[^>]*>\s*$', re.IGNORECASE)
304
308
  _CLOSE_DIV_RE = re.compile(r'^\s*</div>\s*$', re.IGNORECASE)
305
309
 
310
+ # An attr-list line opting a block into card styling: { .card }
311
+ _CARD_ATTR_RE = re.compile(r'^\s*\{:?\s*\.card\b[^}]*\}\s*$')
306
312
 
307
- def _tokenize_grid_cards(inner_lines: list[str]) -> _GridCardsToken:
308
- """Convert the inner lines of a grid cards div into a ``_GridCardsToken``.
309
313
 
310
- Two card formats are supported:
314
+ def _grid_kind(line: str) -> str | None:
315
+ """Return ``"cards"``, ``"grid"``, or ``None`` for a ``<div>`` opener line.
311
316
 
312
- * **Bullet list** — each list item is one card::
317
+ ``grid cards`` wins over ``grid``; a div without a ``grid`` class is not ours.
318
+ """
319
+ m = _DIV_CLASS_RE.match(line)
320
+ if m is None:
321
+ return None
322
+ classes = m.group("classes").split()
323
+ if "grid" not in classes:
324
+ return None
325
+ return "cards" if "cards" in classes else "grid"
326
+
327
+
328
+ # Matches a top-level card bullet (no leading indent): ``- ``, ``* ``, ``+ ``.
329
+ _CARD_BULLET_RE = re.compile(r'^(?P<marker>[-*+]\s+)(?P<text>.*)$')
330
+
331
+
332
+ def _expand_leading_tabs(line: str) -> str:
333
+ """Expand tabs in *line*'s leading whitespace to 4-space tab stops.
334
+
335
+ Indent width drives every block decision below, and Markdown counts a tab as
336
+ 4 columns. Only the indent is touched — tabs inside code or text are content.
337
+ """
338
+ stripped = line.lstrip(" \t")
339
+ indent = line[: len(line) - len(stripped)]
340
+ return indent.expandtabs(4) + stripped
341
+
342
+
343
+ def _split_card_blocks(lines: list[str]) -> list[list[str]] | None:
344
+ """Split *lines* into one block of lines per top-level bullet.
345
+
346
+ The bullet marker is removed from the first line of each block and its
347
+ continuation lines are dedented by the marker width, so each block reads as
348
+ standalone Markdown. Returns ``None`` when there are no top-level bullets,
349
+ leaving the caller on its non-bullet path.
350
+ """
351
+ starts = [
352
+ (i, m) for i, ln in enumerate(lines) if (m := _CARD_BULLET_RE.match(ln)) is not None
353
+ ]
354
+ if not starts:
355
+ return None
356
+
357
+ blocks: list[list[str]] = []
358
+ for n, (start, m) in enumerate(starts):
359
+ end = starts[n + 1][0] if n + 1 < len(starts) else len(lines)
360
+ dedent = len(m.group("marker"))
361
+ body: list[str] = []
362
+ for raw in lines[start + 1:end]:
363
+ ln = _expand_leading_tabs(raw)
364
+ # Only strip indentation that is actually whitespace; a line that is
365
+ # under-indented keeps whatever it has rather than losing content.
366
+ body.append(ln[dedent:] if ln[:dedent].isspace() else ln)
367
+ blocks.append([m.group("text"), *_strip_insignificant_indent(body)])
368
+ return blocks
369
+
370
+
371
+ def _strip_insignificant_indent(lines: list[str]) -> list[str]:
372
+ """Remove a common leading indent of 1-3 spaces from *lines*.
373
+
374
+ Authors routinely indent card bodies by 4 while the list marker is only 2
375
+ wide, leaving 2 stray spaces after the marker dedent. Markdown treats fewer
376
+ than 4 spaces as insignificant, but our block patterns anchor at ``^``, so
377
+ those strays would stop a ``---``, a code fence or a heading from being
378
+ recognised. An indent of 4+ is left alone: that is what delimits an
379
+ admonition body, and removing it would detach the body from its opener.
380
+ """
381
+ indents = [len(ln) - len(ln.lstrip()) for ln in lines if ln.strip()]
382
+ common = min(indents, default=0)
383
+ if not 0 < common < 4:
384
+ return lines
385
+ return [ln[common:] if ln.strip() else ln for ln in lines]
386
+
387
+
388
+ def _tokenize_grid(inner_lines: list[str], *, kind: str) -> _GridCardsToken:
389
+ """Convert the inner lines of a Material grid div into a ``_GridCardsToken``.
390
+
391
+ *kind* is ``"cards"`` for ``<div class="grid cards">`` or ``"grid"`` for a
392
+ plain ``<div class="grid">``. Both map onto the same Confluence layout; they
393
+ differ only in what counts as one item:
394
+
395
+ * ``cards`` — each top-level bullet is a card, and the bullet marker is
396
+ stripped so the item's own block content (admonition, nested list, code
397
+ block) parses normally::
313
398
 
314
399
  - :icon: **Title** — description text
400
+ - !!! tip "Title"
315
401
 
316
- * **Admonition list** — each admonition is one card::
402
+ With no bullets present, each top-level block is a card instead, which is
403
+ the admonition-list form::
317
404
 
318
405
  !!! tip "Title"
319
406
  Body content
407
+
408
+ * ``grid`` — each top-level block is one grid item and keeps its own
409
+ rendering; a bullet list is a single item, not one item per bullet.
410
+ ``{ .card }`` styling hints are dropped, having no Confluence equivalent.
320
411
  """
321
- inner_tokens = _tokenize("\n".join(inner_lines))
412
+ if kind == "cards":
413
+ blocks = _split_card_blocks(inner_lines)
414
+ if blocks is not None:
415
+ return _GridCardsToken(cards=[_tokenize("\n".join(b)) for b in blocks])
416
+ else:
417
+ inner_lines = [ln for ln in inner_lines if not _CARD_ATTR_RE.match(ln)]
322
418
 
323
- # Bullet list: each item becomes a card (a single paragraph).
324
- if len(inner_tokens) == 1 and isinstance(inner_tokens[0], _BulletListToken):
325
- cards: list[list[_Token]] = [
326
- [_ParagraphToken(lines=[item.text])]
327
- for item in inner_tokens[0].items
328
- ]
329
- return _GridCardsToken(cards=cards)
419
+ inner_tokens = _tokenize("\n".join(inner_lines))
330
420
 
331
- # All other cases: each top-level token is one card.
421
+ # Each top-level token is one item.
332
422
  return _GridCardsToken(cards=[[tok] for tok in inner_tokens])
333
423
 
334
424
 
@@ -344,13 +434,15 @@ def _tokenize(text: str) -> list[_Token]:
344
434
  while i < len(lines):
345
435
  line = lines[i]
346
436
 
347
- # ── Grid cards div ───────────────────────────────────────────────────
348
- if _GRID_CARD_DIV_RE.match(line):
437
+ # ── Grid div (``grid cards`` or plain ``grid``) ──────────────────────
438
+ grid_kind = _grid_kind(line)
439
+ if grid_kind is not None:
349
440
  i += 1
350
441
  inner_lines: list[str] = []
351
442
  depth = 1
352
443
  while i < len(lines) and depth > 0:
353
- if _GRID_CARD_DIV_RE.match(lines[i]):
444
+ # Any nested <div> must be counted, or its </div> closes the grid early.
445
+ if _ANY_DIV_OPEN_RE.match(lines[i]):
354
446
  depth += 1
355
447
  elif _CLOSE_DIV_RE.match(lines[i]):
356
448
  depth -= 1
@@ -360,7 +452,7 @@ def _tokenize(text: str) -> list[_Token]:
360
452
  if depth > 0:
361
453
  inner_lines.append(lines[i])
362
454
  i += 1
363
- tokens.append(_tokenize_grid_cards(inner_lines))
455
+ tokens.append(_tokenize_grid(inner_lines, kind=grid_kind))
364
456
  continue
365
457
 
366
458
  # ── Fenced code block ────────────────────────────────────────────────
@@ -1207,7 +1299,9 @@ def _build_tree(
1207
1299
  def _make_cell(
1208
1300
  text: str, col: int, is_header: bool = False
1209
1301
  ) -> TableCell:
1210
- align = aligns[col] if col < len(aligns) else None
1302
+ # B023 is suppressed below: _make_cell is defined and fully consumed
1303
+ # inside a single loop iteration, so aligns cannot be rebound under it.
1304
+ align = aligns[col] if col < len(aligns) else None # noqa: B023
1211
1305
  return TableCell(
1212
1306
  children=_parse_inline(text, fn_map=_fn),
1213
1307
  align=align,
@@ -294,7 +294,7 @@ def _parse_spec(
294
294
  raise IncludeError(
295
295
  f"{source_path}:{lineno}: named-section includes are not yet "
296
296
  f"supported: {raw_spec!r}"
297
- )
297
+ ) from None
298
298
  if start < 1 or end < start:
299
299
  raise IncludeError(
300
300
  f"{source_path}:{lineno}: invalid line range {start}:{end} "
@@ -7,6 +7,7 @@ Authentication is HTTP Basic with email + API token.
7
7
  from __future__ import annotations
8
8
 
9
9
  import base64
10
+ from functools import partial
10
11
  from pathlib import Path
11
12
  from typing import Any, cast
12
13
  from urllib.parse import parse_qs, urlparse
@@ -378,7 +379,9 @@ class ConfluenceClient:
378
379
  name = lbl.get("name", "")
379
380
  if name:
380
381
  http_request_with_retry(
381
- lambda: self._http.delete(label_url, params={"name": name}),
382
+ # partial binds name now; a closure would late-bind and could
383
+ # delete the wrong label if the helper ever defers the call.
384
+ partial(self._http.delete, label_url, params={"name": name}),
382
385
  f"set_page_labels({page_id!r})",
383
386
  )
384
387
 
@@ -90,13 +90,13 @@ def install_skill(
90
90
  dest.write_text(content_body, encoding="utf-8")
91
91
  installed.append(("claude", dest))
92
92
 
93
- # GitHub Copilot — project-level, body only as .instructions.md
93
+ # GitHub Copilot — .instructions.md needs applyTo frontmatter or Copilot ignores it
94
94
  if _want("copilot"):
95
95
  copilot_marker = project_dir / ".github" / "copilot-instructions.md"
96
96
  if explicit or copilot_marker.exists():
97
97
  dest = project_dir / ".github" / "instructions" / "mk2conf-changelog.instructions.md"
98
98
  dest.parent.mkdir(parents=True, exist_ok=True)
99
- dest.write_text(content_body, encoding="utf-8")
99
+ dest.write_text(f'---\napplyTo: "**"\n---\n\n{content_body}', encoding="utf-8")
100
100
  installed.append(("copilot", dest))
101
101
 
102
102
  # Cursor — project-level, body as .mdc
@@ -53,7 +53,10 @@ def kroki_post(source: str, diagram_type: str, kroki_url: str, fmt: str = "png")
53
53
  """POST *source* to Kroki for *diagram_type* and return rendered bytes."""
54
54
  url = f"{kroki_url.rstrip('/')}/{diagram_type}/{fmt}"
55
55
  body = source.encode("utf-8")
56
- req = urllib.request.Request(
56
+ # kroki_url comes from config, so the scheme is not attacker-controlled, but it is
57
+ # also not validated. Switching this to httpx (already a dependency) would restrict
58
+ # it to http/https and remove the urllib import entirely.
59
+ req = urllib.request.Request( # noqa: S310
57
60
  url,
58
61
  data=body,
59
62
  headers={
@@ -62,7 +62,7 @@ def _mermaid_ink_png(source: str) -> bytes:
62
62
  compressed = zlib.compress(payload.encode("utf-8"))
63
63
  encoded = base64.urlsafe_b64encode(compressed).decode().rstrip("=")
64
64
  url = f"{_MERMAID_INK_URL}/img/pako:{encoded}?type=png"
65
- req = urllib.request.Request(url, headers={"User-Agent": "mk2conf/1.0"})
65
+ req = urllib.request.Request(url, headers={"User-Agent": "mk2conf/1.0"}) # noqa: S310
66
66
  with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: # noqa: S310 # nosec B310
67
67
  return cast(bytes, resp.read())
68
68
 
@@ -1460,3 +1460,211 @@ class TestAnchorNode:
1460
1460
  assert '<ac:link ac:anchor="section-1">' in xhtml
1461
1461
  assert 'ac:structured-macro ac:name="anchor"' in xhtml
1462
1462
  assert "section-1" in xhtml
1463
+
1464
+
1465
+ class TestAdmonitionOpenerWhitespace:
1466
+ """Opener lines survive trailing whitespace and <=3 spaces of indent."""
1467
+
1468
+ @pytest.mark.parametrize("opener", [
1469
+ '!!! abstract "Description"',
1470
+ '!!! abstract "Description" ', # trailing space
1471
+ ' !!! abstract "Description"', # 3-space indent
1472
+ '??? tip "T" ',
1473
+ ])
1474
+ def test_opener_variants_parse(self, opener: str) -> None:
1475
+ nodes = parse(f"{opener}\n\n Body text.\n")
1476
+ assert isinstance(nodes[0], Admonition), f"failed for {opener!r}"
1477
+
1478
+ def test_four_space_indent_is_not_an_admonition(self) -> None:
1479
+ """4 spaces is an indented code block, not an opener."""
1480
+ nodes = parse("text\n\n !!! abstract \"x\"\n")
1481
+ assert not any(isinstance(n, Admonition) for n in nodes)
1482
+
1483
+
1484
+ class TestGridCardAdmonitions:
1485
+ """`- !!! kind "Title"` inside grid cards is a card, not raw paragraph text."""
1486
+
1487
+ DIV = '<div class="grid cards" markdown>\n\n'
1488
+ END = '\n</div>\n'
1489
+
1490
+ def _emit(self, body: str) -> str:
1491
+ from mkdocs_to_confluence.emitter.xhtml import emit
1492
+ return emit(parse(self.DIV + body + self.END))
1493
+
1494
+ def test_bulleted_admonitions_become_admonition_cards(self) -> None:
1495
+ xhtml = self._emit(
1496
+ '- !!! abstract "Description"\n This is a test\n\n'
1497
+ '- !!! note "Test me"\n This is hello\n'
1498
+ )
1499
+ assert "!!!" not in xhtml, "admonition opener leaked as raw text"
1500
+ assert xhtml.count('ac:structured-macro ac:name="info"') == 2
1501
+ assert xhtml.count("<ac:layout-cell>") == 2
1502
+ assert "This is a test" in xhtml and "This is hello" in xhtml
1503
+
1504
+ def test_card_body_may_contain_a_bullet_list(self) -> None:
1505
+ """A nested list inside a card must not disable admonition detection."""
1506
+ xhtml = self._emit('- !!! tip "T"\n\n - one\n - two\n')
1507
+ assert "!!!" not in xhtml
1508
+ assert 'ac:structured-macro ac:name="tip"' in xhtml
1509
+ assert "<li><p>one</p></li>" in xhtml
1510
+
1511
+ def test_icon_cards_and_admonition_cards_can_mix(self) -> None:
1512
+ xhtml = self._emit('- :material-cog: **Setup**\n\n- !!! tip "T"\n\n Body.\n')
1513
+ assert "!!!" not in xhtml
1514
+ assert "<strong>Setup</strong>" in xhtml
1515
+ assert 'ac:structured-macro ac:name="tip"' in xhtml
1516
+ assert xhtml.count("<ac:layout-cell>") == 2
1517
+
1518
+ def test_card_body_may_contain_a_code_block(self) -> None:
1519
+ xhtml = self._emit('- !!! tip "T"\n\n ```python\n x = 1\n ```\n')
1520
+ assert 'ac:name="code"' in xhtml
1521
+ assert "x = 1" in xhtml
1522
+
1523
+ def test_icon_cards_are_left_alone(self) -> None:
1524
+ xhtml = self._emit('- :material-cog: **Setup**\n- :material-run: **Run**\n')
1525
+ assert "ac:structured-macro" not in xhtml
1526
+ assert "<strong>Setup</strong>" in xhtml
1527
+ assert xhtml.count("<ac:layout-cell>") == 2
1528
+
1529
+ def test_icon_card_keeps_its_description_paragraph(self) -> None:
1530
+ xhtml = self._emit('- :material-cog: **Setup**\n\n Description text.\n')
1531
+ assert "Description text." in xhtml
1532
+
1533
+ def test_non_admonition_triple_marker_stays_literal(self) -> None:
1534
+ """`!?!` is not an admonition marker and must render as text."""
1535
+ assert "!?!" in self._emit('- !?! not an admonition\n')
1536
+
1537
+
1538
+ class TestGenericGrid:
1539
+ """`<div class="grid">` — each top-level block is one grid item."""
1540
+
1541
+ def _emit(self, src: str) -> str:
1542
+ from mkdocs_to_confluence.emitter.xhtml import emit
1543
+ return emit(parse(src))
1544
+
1545
+ def _grid(self, body: str, classes: str = "grid") -> str:
1546
+ return f'<div class="{classes}" markdown>\n\n{body}\n</div>\n'
1547
+
1548
+ def test_admonitions_become_grid_items(self) -> None:
1549
+ xhtml = self._emit(self._grid(
1550
+ '!!! tip "A"\n\n Body A\n\n!!! note "B"\n\n Body B\n'
1551
+ ))
1552
+ assert "&lt;div" not in xhtml, "grid div leaked as literal text"
1553
+ assert xhtml.count("<ac:layout-cell>") == 2
1554
+ assert 'ac:structured-macro ac:name="tip"' in xhtml
1555
+
1556
+ def test_bullet_list_is_one_item_not_one_per_bullet(self) -> None:
1557
+ """The `cards` card-splitting must not apply to a plain grid."""
1558
+ xhtml = self._emit(self._grid("- one\n- two\n"))
1559
+ assert xhtml.count("<ac:layout-cell>") == 1
1560
+ assert "<li><p>one</p></li>" in xhtml
1561
+
1562
+ def test_card_attr_list_is_dropped(self) -> None:
1563
+ """`{ .card }` is a styling hint with no Confluence equivalent."""
1564
+ xhtml = self._emit(self._grid(
1565
+ "**HTML** for structure\n{ .card }\n\n**JS** for logic\n{ .card }\n"
1566
+ ))
1567
+ assert ".card" not in xhtml
1568
+ assert xhtml.count("<ac:layout-cell>") == 2
1569
+
1570
+ def test_grid_cards_still_splits_on_bullets(self) -> None:
1571
+ """Regression guard: `grid cards` keeps one-card-per-bullet."""
1572
+ xhtml = self._emit(self._grid("- one\n- two\n", classes="grid cards"))
1573
+ assert xhtml.count("<ac:layout-cell>") == 2
1574
+
1575
+ def test_class_order_does_not_matter(self) -> None:
1576
+ xhtml = self._emit(self._grid("- one\n- two\n", classes="cards grid"))
1577
+ assert xhtml.count("<ac:layout-cell>") == 2
1578
+
1579
+ def test_extra_classes_are_tolerated(self) -> None:
1580
+ xhtml = self._emit(self._grid("- one\n- two\n", classes="grid cards custom"))
1581
+ assert xhtml.count("<ac:layout-cell>") == 2
1582
+
1583
+ def test_div_without_grid_class_is_not_a_grid(self) -> None:
1584
+ xhtml = self._emit('<div class="note" markdown>\n\ntext\n\n</div>\n')
1585
+ assert "ac:layout" not in xhtml
1586
+
1587
+ def test_nested_div_does_not_close_the_grid_early(self) -> None:
1588
+ """An inner </div> must not terminate the grid and strand later items."""
1589
+ xhtml = self._emit(self._grid(
1590
+ '<div class="x">\n</div>\n\n!!! tip "A"\n\n Body\n'
1591
+ ))
1592
+ assert 'ac:structured-macro ac:name="tip"' in xhtml, "item after nested div was lost"
1593
+
1594
+
1595
+ class TestGridCardBodyBlocks:
1596
+ """Card bodies keep block structure; a 2-space marker dedent must not break it."""
1597
+
1598
+ def _emit(self, body: str) -> str:
1599
+ from mkdocs_to_confluence.emitter.xhtml import emit
1600
+ return emit(parse(f'<div class="grid cards" markdown>\n\n{body}\n</div>\n'))
1601
+
1602
+ def test_separator_becomes_a_rule(self) -> None:
1603
+ """Material's canonical card syntax uses `---` as a divider."""
1604
+ xhtml = self._emit("- **T**\n\n ---\n\n Body.\n")
1605
+ assert "<hr/>" in xhtml
1606
+ assert "<p>---</p>" not in xhtml
1607
+
1608
+ def test_code_fence_stays_a_code_block(self) -> None:
1609
+ xhtml = self._emit("- **T**\n\n ```python\n x = 1\n ```\n")
1610
+ assert 'ac:name="code"' in xhtml
1611
+ assert "<![CDATA[x = 1]]>" in xhtml
1612
+ assert "<code>python" not in xhtml, "fence collapsed into inline code"
1613
+
1614
+ def test_heading_in_card_body(self) -> None:
1615
+ xhtml = self._emit("- **T**\n\n ## Sub\n\n Body.\n")
1616
+ assert "<h2>Sub</h2>" in xhtml
1617
+
1618
+ def test_admonition_body_indent_is_preserved(self) -> None:
1619
+ """4+ spaces delimit an admonition body and must survive the dedent."""
1620
+ xhtml = self._emit('- !!! tip "T"\n\n - one\n - two\n')
1621
+ assert 'ac:structured-macro ac:name="tip"' in xhtml
1622
+ # The list must be *inside* the panel, not a sibling after it.
1623
+ body = xhtml.split("<ac:rich-text-body>")[1]
1624
+ assert "<li><p>one</p></li>" in body.split("</ac:rich-text-body>")[0]
1625
+
1626
+ def test_material_canonical_complex_example(self) -> None:
1627
+ """The example straight from the Material grids reference page."""
1628
+ xhtml = self._emit(
1629
+ "- :material-clock-fast:{ .lg .middle } __Set up in 5 minutes__\n\n"
1630
+ " ---\n\n"
1631
+ " Install it and get up and running in minutes\n\n"
1632
+ "- :fontawesome-brands-markdown:{ .lg .middle } __It's just Markdown__\n\n"
1633
+ " ---\n\n"
1634
+ " Focus on your content\n"
1635
+ )
1636
+ assert xhtml.count("<ac:layout-cell>") == 2
1637
+ assert xhtml.count("<hr/>") == 2
1638
+ assert "Set up in 5 minutes" in xhtml and "Focus on your content" in xhtml
1639
+
1640
+
1641
+ class TestGridCardIndentEdgeCases:
1642
+ """Marker width and tab indentation must not detach a card's body."""
1643
+
1644
+ def _emit(self, body: str) -> str:
1645
+ from mkdocs_to_confluence.emitter.xhtml import emit
1646
+ return emit(parse(f'<div class="grid cards" markdown>\n\n{body}\n</div>\n'))
1647
+
1648
+ def _panel_body(self, xhtml: str) -> str:
1649
+ return xhtml.split("<ac:rich-text-body>")[1].split("</ac:rich-text-body>")[0]
1650
+
1651
+ def test_tab_indented_admonition_body_stays_inside_the_panel(self) -> None:
1652
+ xhtml = self._emit('- !!! tip "T"\n\n\t\tBody.\n')
1653
+ assert "Body." in self._panel_body(xhtml), "tab-indented body escaped the panel"
1654
+
1655
+ def test_tab_indented_code_fence_in_card(self) -> None:
1656
+ xhtml = self._emit("- **T**\n\n\t```python\n\tx = 1\n\t```\n")
1657
+ assert "<![CDATA[x = 1]]>" in xhtml
1658
+
1659
+ def test_wide_marker_keeps_separator(self) -> None:
1660
+ xhtml = self._emit("- **T**\n\n ---\n\n Body.\n")
1661
+ assert "<hr/>" in xhtml
1662
+
1663
+ def test_wide_marker_keeps_admonition_body(self) -> None:
1664
+ xhtml = self._emit('- !!! tip "T"\n\n Body.\n')
1665
+ assert "Body." in self._panel_body(xhtml)
1666
+
1667
+ def test_crlf_line_endings(self) -> None:
1668
+ src = '<div class="grid cards" markdown>\r\n\r\n- !!! tip "T"\r\n\r\n Body.\r\n\r\n</div>\r\n'
1669
+ from mkdocs_to_confluence.emitter.xhtml import emit
1670
+ assert "Body." in self._panel_body(emit(parse(src)))
@@ -64,6 +64,8 @@ def test_install_skill_copilot(tmp_path: Path) -> None:
64
64
  assert dest.name == "mk2conf-changelog.instructions.md"
65
65
  content = dest.read_text(encoding="utf-8")
66
66
  assert "name: mkdocs-changelog" not in content
67
+ # Copilot ignores .instructions.md files without applyTo frontmatter
68
+ assert content.startswith('---\napplyTo: "**"\n---\n\n# MkDocs Changelog Entry')
67
69
 
68
70
 
69
71
  def test_install_skill_cursor(tmp_path: Path) -> None: