mkdocs2confluence 0.7.1__tar.gz → 0.7.3__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 (72) hide show
  1. {mkdocs2confluence-0.7.1/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.7.3}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/pyproject.toml +1 -1
  3. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3/src/mkdocs2confluence.egg-info}/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/emitter/xhtml.py +3 -0
  5. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/ir/__init__.py +2 -0
  6. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/ir/nodes.py +12 -0
  7. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/parser/markdown.py +13 -1
  8. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/pdf/render.py +5 -2
  9. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preprocess/icons.py +68 -1
  10. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preview/render.py +2 -1
  11. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_icons.py +46 -0
  12. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_parser.py +19 -0
  13. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_pdf.py +5 -0
  14. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_preview.py +11 -0
  15. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/LICENSE +0 -0
  16. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/README.md +0 -0
  17. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/setup.cfg +0 -0
  18. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  19. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  20. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  21. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  22. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  23. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/__init__.py +0 -0
  24. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/cli.py +0 -0
  25. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  26. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/ir/document.py +0 -0
  27. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  28. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  29. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/loader/config.py +0 -0
  30. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  31. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  32. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/loader/page.py +0 -0
  33. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  34. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  35. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  36. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  37. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  38. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  39. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  40. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  41. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  42. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  43. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/preview/server.py +0 -0
  44. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  45. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  46. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  47. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  48. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  49. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  50. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  51. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  52. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  53. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  54. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_abbrevs.py +0 -0
  55. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_cli.py +0 -0
  56. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_editlink.py +0 -0
  57. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_emitter.py +0 -0
  58. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_extra_css.py +0 -0
  59. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_frontmatter.py +0 -0
  60. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_images.py +0 -0
  61. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_internallinks.py +0 -0
  62. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_ir.py +0 -0
  63. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_linkdefs.py +0 -0
  64. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_loader.py +0 -0
  65. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_mermaid.py +0 -0
  66. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_page_loader.py +0 -0
  67. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_preprocess.py +0 -0
  68. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_publish_client.py +0 -0
  69. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_publish_config.py +0 -0
  70. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_publish_pipeline.py +0 -0
  71. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_server.py +0 -0
  72. {mkdocs2confluence-0.7.1 → mkdocs2confluence-0.7.3}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.7.1
3
+ Version: 0.7.3
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.7.1"
3
+ version = "0.7.3"
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.7.1
3
+ Version: 0.7.3
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
@@ -48,6 +48,7 @@ from mkdocs_to_confluence.ir.nodes import (
48
48
  OrderedList,
49
49
  Paragraph,
50
50
  RawHTML,
51
+ RawInlineHtml,
51
52
  Section,
52
53
  StrikethroughNode,
53
54
  SubscriptNode,
@@ -613,6 +614,8 @@ def _emit_inline(node: IRNode) -> str:
613
614
  if isinstance(node, InlineHtmlNode):
614
615
  open_tag, close_tag = _INLINE_HTML_MAP.get(node.tag, ("<span>", "</span>"))
615
616
  return f"{open_tag}{_emit_inlines(node.children)}{close_tag}"
617
+ if isinstance(node, RawInlineHtml):
618
+ return node.html_str
616
619
  # Fallback: emit unknown inline nodes as escaped repr
617
620
  return html.escape(repr(node))
618
621
 
@@ -44,6 +44,7 @@ from mkdocs_to_confluence.ir.nodes import (
44
44
  OrderedList,
45
45
  Paragraph,
46
46
  RawHTML,
47
+ RawInlineHtml,
47
48
  # Block nodes
48
49
  Section,
49
50
  StrikethroughNode,
@@ -81,6 +82,7 @@ __all__ = [
81
82
  "ImageNode",
82
83
  "LineBreakNode",
83
84
  "InlineHtmlNode",
85
+ "RawInlineHtml",
84
86
  "InsertNode",
85
87
  # Block
86
88
  "Section",
@@ -125,6 +125,18 @@ class InlineHtmlNode(IRNode):
125
125
  children: tuple[IRNode, ...]
126
126
 
127
127
 
128
+ @dataclass(frozen=True)
129
+ class RawInlineHtml(IRNode):
130
+ """A raw inline HTML element passed through verbatim.
131
+
132
+ Used for ``<span class="...">``, ``<abbr>``, and other inline HTML that has
133
+ no semantic IR equivalent. The emitter outputs ``html_str`` unchanged; PDF
134
+ and browser-preview renderers handle it natively.
135
+ """
136
+
137
+ html_str: str
138
+
139
+
128
140
  @dataclass(frozen=True)
129
141
  class ImageNode(IRNode):
130
142
  """An image reference.
@@ -49,6 +49,7 @@ a proper library (e.g. ``markdown-it-py``) later without touching the IR.
49
49
  from __future__ import annotations
50
50
 
51
51
  import contextlib
52
+ import html
52
53
  import re
53
54
  from dataclasses import dataclass, field
54
55
  from typing import Union
@@ -79,6 +80,7 @@ from mkdocs_to_confluence.ir.nodes import (
79
80
  MermaidDiagram,
80
81
  OrderedList,
81
82
  Paragraph,
83
+ RawInlineHtml,
82
84
  Section,
83
85
  StrikethroughNode,
84
86
  SubscriptNode,
@@ -784,7 +786,7 @@ def _scan_inline(text: str, fn_map: dict[str, int] | None = None) -> list[IRNode
784
786
  def flush() -> None:
785
787
  nonlocal buf
786
788
  if buf:
787
- nodes.append(TextNode(text=buf))
789
+ nodes.append(TextNode(text=html.unescape(buf)))
788
790
  buf = ""
789
791
 
790
792
  while i < n:
@@ -962,6 +964,16 @@ def _scan_inline(text: str, fn_map: dict[str, int] | None = None) -> list[IRNode
962
964
  i = close_idx + len(close)
963
965
  continue
964
966
 
967
+ # Generic inline HTML with attributes (e.g. <span class="...">) — pass through verbatim
968
+ generic_m = re.match(
969
+ r"<([a-z][a-z0-9]*)\b[^>]*>.*?</\1>", text[i:], re.DOTALL | re.IGNORECASE
970
+ )
971
+ if generic_m:
972
+ flush()
973
+ nodes.append(RawInlineHtml(html_str=generic_m.group(0)))
974
+ i += len(generic_m.group(0))
975
+ continue
976
+
965
977
  # Bare URL autolink: https:// or http://
966
978
  if text[i : i + 4] in ("http", "ftp:"):
967
979
  url_m = re.match(r"(https?://|ftp://)[^\s<>\[\]\"']+", text[i:])
@@ -115,7 +115,10 @@ body {
115
115
  /* ── Chapters ── */
116
116
  article {
117
117
  page-break-before: always;
118
- string-set: section-title attr(data-title);
118
+ }
119
+ .pdf-chapter-title {
120
+ display: none;
121
+ string-set: section-title content();
119
122
  }
120
123
  article h1 { font-size: 18pt; color: #0052cc; margin-top: 0; }
121
124
  article h2 { font-size: 13pt; color: #0052cc; }
@@ -249,7 +252,7 @@ def build_pdf_html(
249
252
 
250
253
  # Chapters
251
254
  chapter_html = "".join(
252
- '<article id="{anchor}" data-title="{title}">\n{body}\n</article>\n'.format(
255
+ '<article id="{anchor}">\n<span class="pdf-chapter-title">{title}</span>\n{body}\n</article>\n'.format(
253
256
  anchor=_anchor(title),
254
257
  title=_html.escape(title),
255
258
  body=render_html(xhtml),
@@ -24,6 +24,60 @@ _ICON_RE = re.compile(
24
24
  r":(?:material|fontawesome|octicons|simple|twemoji)-[a-z0-9-]+:"
25
25
  )
26
26
 
27
+ # Matches bare GitHub/Python-Markdown emoji shortcodes: :rotating_light:, :wrench: etc.
28
+ # Requires at least one underscore or is a known single-word name.
29
+ # Must not overlap with _ICON_RE (those all contain a hyphenated prefix).
30
+ _STANDARD_EMOJI_RE = re.compile(r":([a-z][a-z0-9_]*):")
31
+
32
+ # Bare emoji shortcode → BMP Unicode symbol (or "" to strip silently).
33
+ # BMP-only where a reasonable equivalent exists; supplementary-plane emoji
34
+ # are excluded to preserve MySQL utf8 Confluence compatibility.
35
+ _STANDARD_EMOJI_MAP: dict[str, str] = {
36
+ # Alerts / status
37
+ "warning": "⚠", # U+26A0
38
+ "rotating_light": "⚠", # U+26A0 (🚨 U+1F6A8 is supplementary)
39
+ "octagonal_sign": "⛔", # U+26D4
40
+ "no_entry": "⛔", # U+26D4
41
+ "no_entry_sign": "⛔",
42
+ "stop_sign": "⛔",
43
+ "information_source": "ℹ", # U+2139
44
+ # Checkmarks / marks
45
+ "white_check_mark": "✓", # U+2713 (✅ supplementary)
46
+ "heavy_check_mark": "✓", # U+2713
47
+ "x": "✗", # U+2717 (❌ supplementary)
48
+ "heavy_multiplication_x": "✗",
49
+ # Objects — tools
50
+ "wrench": "⚙", # U+2699 (🔧 supplementary)
51
+ "gear": "⚙", # U+2699
52
+ "hammer": "", # 🔨 supplementary, no BMP equiv — strip
53
+ "hammer_and_wrench": "",
54
+ # Work / business
55
+ "briefcase": "", # 💼 supplementary — strip
56
+ # Nature / miscellaneous
57
+ "star": "★", # U+2605
58
+ "star2": "★",
59
+ "rocket": "", # 🚀 supplementary — strip
60
+ "construction": "", # 🚧 supplementary — strip
61
+ "tada": "",
62
+ "trophy": "",
63
+ "thinking": "",
64
+ "smile": "",
65
+ "laughing": "",
66
+ "heart": "♥", # U+2665
67
+ "fire": "", # 🔥 supplementary — strip
68
+ "zap": "", # ⚡ U+26A1 BMP
69
+ "bulb": "",
70
+ "computer": "",
71
+ "notebook": "",
72
+ "memo": "",
73
+ "clipboard": "",
74
+ "link": "",
75
+ "label": "",
76
+ "bookmark": "",
77
+ "chart_with_upwards_trend": "",
78
+ "bar_chart": "",
79
+ }
80
+
27
81
  # Keyword → symbol. Keys must be lowercase single words (icon name segments).
28
82
  # ALL values must be BMP characters (U+0000–U+FFFF) or "" (strip silently).
29
83
  # Ordered so earlier, more-specific entries take priority where ambiguous.
@@ -175,6 +229,19 @@ def _resolve(parts: list[str]) -> str:
175
229
  return ""
176
230
 
177
231
 
232
+ def _replace_standard_emoji(text: str) -> str:
233
+ """Replace standard emoji shortcodes (e.g. ``:rotating_light:``) with BMP symbols."""
234
+
235
+ def _replace(m: re.Match[str]) -> str:
236
+ name = m.group(1)
237
+ if name in _STANDARD_EMOJI_MAP:
238
+ return _STANDARD_EMOJI_MAP[name]
239
+ # Unknown shortcode — leave it intact (may be valid Markdown syntax).
240
+ return m.group(0)
241
+
242
+ return _STANDARD_EMOJI_RE.sub(_replace, text)
243
+
244
+
178
245
  def strip_icon_shortcodes(text: str) -> str:
179
246
  """Replace icon shortcodes with emoji or strip them silently.
180
247
 
@@ -194,4 +261,4 @@ def strip_icon_shortcodes(text: str) -> str:
194
261
  parts = name.split("-")
195
262
  return _resolve(parts)
196
263
 
197
- return _ICON_RE.sub(_replace, text)
264
+ return _ICON_RE.sub(_replace, _replace_standard_emoji(text))
@@ -72,7 +72,8 @@ _AC_ANCHOR_ATTR_RE = re.compile(r'ac:anchor="(?P<anchor>[^"]*)"')
72
72
 
73
73
 
74
74
  def _params(macro_body: str) -> dict[str, str]:
75
- return {m.group(1): m.group(2) for m in _PARAM_RE.finditer(macro_body)}
75
+ # Values are XML text content (e.g. "&amp;" for "&"); unescape to plain text.
76
+ return {m.group(1): _html.unescape(m.group(2)) for m in _PARAM_RE.finditer(macro_body)}
76
77
 
77
78
 
78
79
  def _cdata(macro_body: str) -> str:
@@ -157,3 +157,49 @@ class TestInlineReplacement:
157
157
  def test_no_icons_unchanged(self) -> None:
158
158
  text = "No icons here, just plain text."
159
159
  assert strip_icon_shortcodes(text) == text
160
+
161
+
162
+ class TestStandardEmoji:
163
+ """Tests for bare emoji shortcodes like :rotating_light:."""
164
+
165
+ def test_rotating_light_maps_to_warning(self) -> None:
166
+ assert strip_icon_shortcodes(":rotating_light:") == "⚠"
167
+
168
+ def test_octagonal_sign_maps_to_no_entry(self) -> None:
169
+ assert strip_icon_shortcodes(":octagonal_sign:") == "⛔"
170
+
171
+ def test_wrench_maps_to_gear(self) -> None:
172
+ assert strip_icon_shortcodes(":wrench:") == "⚙"
173
+
174
+ def test_information_source_maps_to_info(self) -> None:
175
+ assert strip_icon_shortcodes(":information_source:") == "ℹ"
176
+
177
+ def test_white_check_mark_maps_to_check(self) -> None:
178
+ assert strip_icon_shortcodes(":white_check_mark:") == "✓"
179
+
180
+ def test_x_maps_to_cross(self) -> None:
181
+ assert strip_icon_shortcodes(":x:") == "✗"
182
+
183
+ def test_briefcase_stripped(self) -> None:
184
+ assert strip_icon_shortcodes(":briefcase:") == ""
185
+
186
+ def test_unknown_shortcode_unchanged(self) -> None:
187
+ assert strip_icon_shortcodes(":unknown_emoji_xyz:") == ":unknown_emoji_xyz:"
188
+
189
+ def test_emoji_in_sentence(self) -> None:
190
+ result = strip_icon_shortcodes("Status: :white_check_mark: Done")
191
+ assert result == "Status: ✓ Done"
192
+
193
+ def test_material_icon_not_double_processed(self) -> None:
194
+ # :material-check-circle: should still go through the prefixed path
195
+ result = strip_icon_shortcodes(":material-check-circle:")
196
+ assert result == "✓"
197
+
198
+ def test_all_standard_emoji_values_are_bmp(self) -> None:
199
+ from mkdocs_to_confluence.preprocess.icons import _STANDARD_EMOJI_MAP
200
+ for name, symbol in _STANDARD_EMOJI_MAP.items():
201
+ for ch in symbol:
202
+ assert ord(ch) <= 0xFFFF, (
203
+ f"_STANDARD_EMOJI_MAP[{name!r}] contains supplementary-plane "
204
+ f"character U+{ord(ch):04X} — use BMP or empty string"
205
+ )
@@ -1181,6 +1181,25 @@ class TestInlineHtmlParsing:
1181
1181
  raw = "".join(n.text for n in para.children if isinstance(n, TextNode))
1182
1182
  assert "<mark>" in raw
1183
1183
 
1184
+ def test_span_with_class_produces_raw_inline_html(self) -> None:
1185
+ from mkdocs_to_confluence.ir import RawInlineHtml
1186
+ para = first(parse('Text <span class="crit-level">C0</span> end\n'), Paragraph)
1187
+ node = next(n for n in para.children if isinstance(n, RawInlineHtml))
1188
+ assert node.html_str == '<span class="crit-level">C0</span>'
1189
+
1190
+ def test_span_without_attrs_not_treated_as_raw(self) -> None:
1191
+ # Plain <span> (no attributes) is not in the known-tag list either,
1192
+ # so it falls through to generic handler.
1193
+ from mkdocs_to_confluence.ir import RawInlineHtml
1194
+ para = first(parse("<span>plain</span>\n"), Paragraph)
1195
+ assert any(isinstance(n, RawInlineHtml) for n in para.children)
1196
+
1197
+ def test_raw_inline_html_emitted_verbatim(self) -> None:
1198
+ from mkdocs_to_confluence.emitter.xhtml import emit
1199
+ nodes = parse('| Col |\n|-----|\n| <span class="hl">X</span> |\n')
1200
+ xhtml = emit(nodes)
1201
+ assert '<span class="hl">X</span>' in xhtml
1202
+
1184
1203
 
1185
1204
  class TestKeyboardKeys:
1186
1205
  def test_single_key(self) -> None:
@@ -166,6 +166,11 @@ class TestWritePdf:
166
166
  class TestPdfCli:
167
167
  """Tests for the `pdf` subcommand in cli.py."""
168
168
 
169
+ @pytest.fixture(autouse=True)
170
+ def _skip_macos_reexec(self, monkeypatch: pytest.MonkeyPatch) -> None:
171
+ """Suppress the macOS DYLD re-exec so tests don't try to exec pytest."""
172
+ monkeypatch.setenv("_MK2CONF_DYLD_SET", "1")
173
+
169
174
  def _run(self, argv: list[str], config_path: Path) -> None:
170
175
  from mkdocs_to_confluence.cli import main
171
176
  main(["--"] + argv if argv[0].startswith("-") else argv)
@@ -87,6 +87,17 @@ class TestRenderHtml:
87
87
  assert "Show more" in out
88
88
  assert "hidden content" in out
89
89
 
90
+ def test_expand_title_with_ampersand_not_double_escaped(self) -> None:
91
+ xhtml = (
92
+ '<ac:structured-macro ac:name="expand">'
93
+ '<ac:parameter ac:name="title">Architecture &amp; Resilience</ac:parameter>'
94
+ "<ac:rich-text-body><p>content</p></ac:rich-text-body>"
95
+ "</ac:structured-macro>"
96
+ )
97
+ out = render_html(xhtml)
98
+ assert "Architecture &amp; Resilience" in out
99
+ assert "&amp;amp;" not in out
100
+
90
101
  def test_nested_code_inside_warning(self) -> None:
91
102
  xhtml = (
92
103
  '<ac:structured-macro ac:name="warning">'