markdown-to-confluence 0.4.2__py3-none-any.whl → 0.4.3__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Author-email: Levente Hunyadi <hunyadi@gmail.com>
6
6
  Maintainer-email: Levente Hunyadi <hunyadi@gmail.com>
@@ -58,7 +58,7 @@ This Python package
58
58
  * Text with **bold**, *italic*, `monospace`, <ins>underline</ins> and ~~strikethrough~~
59
59
  * Link to [sections on the same page](#getting-started) or [external locations](http://example.com/)
60
60
  * Subscript and superscript (with HTML tags `<sub>` and `<sup>`)
61
- * Math formulas with LaTeX notation [^math]
61
+ * Math formulas with LaTeX notation
62
62
  * Emoji
63
63
  * Ordered and unordered lists
64
64
  * Block quotes
@@ -69,8 +69,8 @@ This Python package
69
69
  * [Table of contents](https://docs.gitlab.com/ee/user/markdown.html#table-of-contents)
70
70
  * [Admonitions](https://python-markdown.github.io/extensions/admonition/) and alert boxes in [GitHub](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) and [GitLab](https://docs.gitlab.com/ee/development/documentation/styleguide/#alert-boxes)
71
71
  * [Collapsed sections](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections)
72
- * draw\.io diagrams [^drawio]
73
- * [Mermaid diagrams](https://mermaid.live/) in code blocks (converted to images) [^mermaid]
72
+ * draw\.io diagrams
73
+ * [Mermaid diagrams](https://mermaid.live/) in code blocks (converted to images)
74
74
 
75
75
  Whenever possible, the implementation uses [Confluence REST API v2](https://developer.atlassian.com/cloud/confluence/rest/v2/) to fetch space properties, and get, create or update page content.
76
76
 
@@ -84,9 +84,9 @@ pip install markdown-to-confluence
84
84
 
85
85
  ### Command-line utilities
86
86
 
87
- **Optional.** Converting `*.drawio` diagrams into PNG or SVG images requires installing [draw.io](https://www.drawio.com/). (Refer to `--render-drawio`.)
87
+ **Optional.** Converting `*.drawio` diagrams to PNG or SVG images before uploading to Confluence as attachments requires installing [draw.io](https://www.drawio.com/). (Refer to `--render-drawio`.)
88
88
 
89
- **Optional.** Converting code blocks of Mermaid diagrams into PNG or SVG images requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). (Refer to `--render-mermaid`.)
89
+ **Optional.** Converting code blocks of Mermaid diagrams to PNG or SVG images before uploading to Confluence as attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). (Refer to `--render-mermaid`.)
90
90
 
91
91
  ```sh
92
92
  npm install -g @mermaid-js/mermaid-cli
@@ -98,9 +98,9 @@ As authors of *md2conf*, we don't endorse or support any particular Confluence m
98
98
 
99
99
  **Optional.** Editable draw\.io diagrams require [draw.io Diagrams marketplace app](https://marketplace.atlassian.com/apps/1210933/draw-io-diagrams-uml-bpmn-aws-erd-flowcharts). (Refer to `--no-render-drawio`.)
100
100
 
101
- **Optional.** Displaying Mermaid diagrams in Confluence without pre-rendering requires a marketplace app. (Refer to `--no-render-mermaid`.)
101
+ **Optional.** Displaying Mermaid diagrams in Confluence without pre-rendering in the synchronization phase requires a marketplace app. (Refer to `--no-render-mermaid`.)
102
102
 
103
- **Optional.** Displaying formulas and equations requires [LaTeX Math marketplace app](https://marketplace.atlassian.com/apps/1226109/latex-math-for-confluence-math-formula-equations).
103
+ **Optional.** Displaying formulas and equations in Confluence requires [marketplace app](https://marketplace.atlassian.com/apps/1226109/latex-math-for-confluence-math-formula-equations), refer to [LaTeX Math for Confluence - Math Formula & Equations](https://help.narva.net/latex-math-for-confluence/).
104
104
 
105
105
  ## Getting started
106
106
 
@@ -523,7 +523,3 @@ FROM leventehunyadi/md2conf:latest
523
523
 
524
524
  CMD ["-d", "example.atlassian.net", "-u", "levente.hunyadi@instructure.com", "-a", "0123456789abcdef", "-s", "SPACE", "./"]
525
525
  ```
526
-
527
- [^math]: Requires installing Confluence plugin [LaTeX Math for Confluence - Math Formula & Equations](https://help.narva.net/latex-math-for-confluence/).
528
- [^drawio]: Converting draw\.io diagrams to images before uploading to Confluence requires an installation of [draw.io](https://www.drawio.com/). Editable draw\.io diagrams require separate marketplace app.
529
- [^mermaid]: Converting Mermaid diagrams to images before uploading to Confluence requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli). Displaying Mermaid diagrams on the fly requires separate marketplace app.
@@ -1,27 +1,29 @@
1
- markdown_to_confluence-0.4.2.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
- md2conf/__init__.py,sha256=XAJAgUDrYS3PdOzoo2BQ-rM3PbQWOrOW2kPt5iJ8xY0,402
3
- md2conf/__main__.py,sha256=qyboDihyVTm0EZa_c3AFWF1AojhL8-bww_kISAa5nHQ,10130
1
+ markdown_to_confluence-0.4.3.dist-info/licenses/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
2
+ md2conf/__init__.py,sha256=ZEoZwOt29zT2OnQNpbYW9lO3zJEJ6soXwwjYX9PwNNo,402
3
+ md2conf/__main__.py,sha256=RImfFrO2m9C5iebmBrHKlLjosy_A8AY4O7PK9CmiWSw,11120
4
4
  md2conf/api.py,sha256=DbG1udDb9ti4OjqgSW3DSuHwxKNFPVDTkhjnaB1GNMI,37193
5
- md2conf/application.py,sha256=X_V4KdFACHwl5Nt4BIHQyhtecOqNOzknrPyPTW0d4Z0,8185
5
+ md2conf/application.py,sha256=MsumqUFw1WPo6-57r06Poq4wg2DPd3hQ4jA5qC4Oios,8212
6
6
  md2conf/collection.py,sha256=EobgMRJgkYloWlY03NZJ52MRC_SGLpTVCHkltDbQyt0,837
7
- md2conf/converter.py,sha256=Eg6emS77GQAkhbXutaRjHxcBFgmXp_4z_zOAmBfqxUY,54360
8
- md2conf/drawio.py,sha256=G_pD2nafl7dXuFK_4MBiEUl0ZZGNuagnHw6GFOrev94,6717
7
+ md2conf/converter.py,sha256=mr5UvEhOnM7ZYRIGsgrW85PpxmlpXFjsKYsa8uGFxp0,50475
8
+ md2conf/domain.py,sha256=tA9V0vb5Vo9Nt0eQvwAFARaM9TX88LBVQ73nVvdcaqA,1851
9
+ md2conf/drawio.py,sha256=P_t7Wp7Tg9XkZM2ZchWCWWEdBaU1KgZ_YX9ZlkZo4Dk,8293
9
10
  md2conf/emoji.py,sha256=UzDrxqFo59wHmbbJmMNdn0rYFDXbZE4qirOM-_egzXc,2603
10
11
  md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
11
12
  md2conf/extra.py,sha256=VuMxuOnnC2Qwy6y52ukIxsaYhrZArRqMmRHRE4QZl8g,687
12
- md2conf/local.py,sha256=Ph-cGc_JQ1SkvuZ_Jxn37dlpaKZYKLVPBnsk5CGSVnk,3548
13
+ md2conf/local.py,sha256=Cicfp9SJDJuX0aUWZPWCfWKPfQQWxEbifUsmqwxFjDU,3733
14
+ md2conf/markdown.py,sha256=9BQbYD4GfpBYmx-3N1M36u2nVWY0VJ9UWKye2Jtnmnk,2901
13
15
  md2conf/matcher.py,sha256=m5rZjYZSjhKfdeKS8JdPq7cG861Mc6rVZBkrIOZTHGE,6916
14
16
  md2conf/mermaid.py,sha256=f0x7ISj-41ZMh4zTAFPhIWwr94SDcsVZUc1NWqmH_G4,2508
15
17
  md2conf/metadata.py,sha256=LzZM-oPNnzCULmLhF516tPlV5zZBknccwMHt8Nan-xg,1007
16
- md2conf/processor.py,sha256=59XDWKgTvJwEZ1y52VfRkM67K2-Ivh7kGD6Eg2tfG9c,9713
18
+ md2conf/processor.py,sha256=z2d2KMPEYWaxflOtH2UTwrjzpPU8TtLSEUvor85ez1Q,9732
17
19
  md2conf/properties.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
18
20
  md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
19
21
  md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
22
  md2conf/scanner.py,sha256=Cyvjab8tBvKgubttQvNagS8nailuTvFBqUGoiX5MNp8,5351
21
23
  md2conf/xml.py,sha256=HoKJfF1yRZ3Gk8jTS-kRpOqVs0nQJZyr56l0Fo3y9fs,2193
22
- markdown_to_confluence-0.4.2.dist-info/METADATA,sha256=ggIrg30RRTvk3ax2LKC_wKjMhOEhrXOYVrSwvb2dDbw,29495
23
- markdown_to_confluence-0.4.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
- markdown_to_confluence-0.4.2.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
25
- markdown_to_confluence-0.4.2.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
26
- markdown_to_confluence-0.4.2.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
27
- markdown_to_confluence-0.4.2.dist-info/RECORD,,
24
+ markdown_to_confluence-0.4.3.dist-info/METADATA,sha256=sU45yX796M_cZ0ssGMaDtuxb0xwtKaPTktNAy2rczMg,29119
25
+ markdown_to_confluence-0.4.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
+ markdown_to_confluence-0.4.3.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
27
+ markdown_to_confluence-0.4.3.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
28
+ markdown_to_confluence-0.4.3.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
29
+ markdown_to_confluence-0.4.3.dist-info/RECORD,,
md2conf/__init__.py CHANGED
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.4.2"
8
+ __version__ = "0.4.3"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -17,14 +17,9 @@ import typing
17
17
  from pathlib import Path
18
18
  from typing import Any, Iterable, Literal, Optional, Sequence, Union
19
19
 
20
- import requests
21
-
22
20
  from . import __version__
23
- from .api import ConfluenceAPI
24
- from .application import Application
25
- from .converter import ConfluenceDocumentOptions, ConfluencePageID
21
+ from .domain import ConfluenceDocumentOptions, ConfluencePageID
26
22
  from .extra import override
27
- from .local import LocalConverter
28
23
  from .metadata import ConfluenceSiteMetadata
29
24
  from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceSiteProperties
30
25
 
@@ -52,7 +47,7 @@ class Arguments(argparse.Namespace):
52
47
 
53
48
 
54
49
  class KwargsAppendAction(argparse.Action):
55
- """Append key-value pairs to a dictionary"""
50
+ """Append key-value pairs to a dictionary."""
56
51
 
57
52
  @override
58
53
  def __call__(
@@ -72,6 +67,30 @@ class KwargsAppendAction(argparse.Action):
72
67
  setattr(namespace, self.dest, d)
73
68
 
74
69
 
70
+ def unsupported(prefer: str) -> type[argparse.Action]:
71
+ class UnsupportedAction(argparse.Action):
72
+ """Display an error for unsupported command-line options."""
73
+
74
+ @override
75
+ def __call__(
76
+ self,
77
+ parser: argparse.ArgumentParser,
78
+ namespace: argparse.Namespace,
79
+ values: Union[None, str, Sequence[Any]],
80
+ option_string: Optional[str] = None,
81
+ ) -> None:
82
+ raise argparse.ArgumentError(
83
+ self,
84
+ f"this command-line option is no longer supported, use `--{prefer}`",
85
+ )
86
+
87
+ @override
88
+ def __repr__(self) -> str:
89
+ return f"{unsupported.__name__}({repr(prefer)})"
90
+
91
+ return UnsupportedAction
92
+
93
+
75
94
  class PositionalOnlyHelpFormatter(argparse.HelpFormatter):
76
95
  def _format_usage(
77
96
  self,
@@ -189,12 +208,18 @@ def main() -> None:
189
208
  help="Inline Mermaid diagram in Confluence page. (Marketplace app required.)",
190
209
  )
191
210
  parser.add_argument(
192
- "--render-mermaid-format",
211
+ "--diagram-output-format",
193
212
  dest="diagram_output_format",
194
213
  choices=["png", "svg"],
195
214
  default="png",
196
215
  help="Format for rendering Mermaid and draw.io diagrams (default: 'png').",
197
216
  )
217
+ parser.add_argument(
218
+ "--render-mermaid-format",
219
+ action=unsupported("diagram-output-format"),
220
+ metavar="FORMAT",
221
+ help="Format for rendering Mermaid diagrams (default: 'png').",
222
+ )
198
223
  parser.add_argument(
199
224
  "--heading-anchors",
200
225
  action="store_true",
@@ -256,6 +281,8 @@ def main() -> None:
256
281
  webui_links=args.webui_links,
257
282
  )
258
283
  if args.local:
284
+ from .local import LocalConverter
285
+
259
286
  try:
260
287
  site_properties = ConfluenceSiteProperties(
261
288
  domain=args.domain,
@@ -271,6 +298,11 @@ def main() -> None:
271
298
  )
272
299
  LocalConverter(options, site_metadata).process(args.mdpath)
273
300
  else:
301
+ from requests import HTTPError, JSONDecodeError
302
+
303
+ from .api import ConfluenceAPI
304
+ from .application import Application
305
+
274
306
  try:
275
307
  properties = ConfluenceConnectionProperties(
276
308
  api_url=args.api_url,
@@ -289,14 +321,14 @@ def main() -> None:
289
321
  api,
290
322
  options,
291
323
  ).process(args.mdpath)
292
- except requests.exceptions.HTTPError as err:
324
+ except HTTPError as err:
293
325
  logging.error(err)
294
326
 
295
327
  # print details for a response with JSON body
296
328
  if err.response is not None:
297
329
  try:
298
330
  logging.error(err.response.json())
299
- except requests.exceptions.JSONDecodeError:
331
+ except JSONDecodeError:
300
332
  pass
301
333
 
302
334
  sys.exit(1)
md2conf/application.py CHANGED
@@ -11,7 +11,8 @@ from pathlib import Path
11
11
  from typing import Optional
12
12
 
13
13
  from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession, ConfluenceStatus
14
- from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID, attachment_name, elements_from_string, get_volatile_attributes
14
+ from .converter import ConfluenceDocument, attachment_name, elements_from_string, get_volatile_attributes
15
+ from .domain import ConfluenceDocumentOptions, ConfluencePageID
15
16
  from .extra import override, path_relative_to
16
17
  from .metadata import ConfluencePageMetadata
17
18
  from .processor import Converter, DocumentNode, Processor, ProcessorFactory
@@ -158,7 +159,7 @@ class SynchronizingProcessor(Processor):
158
159
  ):
159
160
  self.api.update_page(page_id.page_id, content, title=title, version=page.version.number + 1)
160
161
  else:
161
- LOGGER.info("Up-to-date page: %s", page_id)
162
+ LOGGER.info("Up-to-date page: %s", page_id.page_id)
162
163
 
163
164
  if document.labels is not None:
164
165
  self.api.update_labels(
md2conf/converter.py CHANGED
@@ -8,28 +8,27 @@ Copyright 2022-2025, Levente Hunyadi
8
8
 
9
9
  # mypy: disable-error-code="dict-item"
10
10
 
11
+ import dataclasses
11
12
  import hashlib
12
13
  import importlib.resources as resources
13
14
  import logging
14
15
  import os.path
15
16
  import re
16
17
  import uuid
17
- import xml.etree.ElementTree
18
18
  from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
  from typing import Any, Literal, Optional, Union
21
21
  from urllib.parse import ParseResult, quote_plus, urlparse, urlunparse
22
22
 
23
23
  import lxml.etree as ET
24
- import markdown
25
24
  from lxml.builder import ElementMaker
26
25
  from strong_typing.core import JsonType
27
26
 
28
- from md2conf.drawio import extract_diagram
29
-
27
+ from . import drawio, mermaid
30
28
  from .collection import ConfluencePageCollection
29
+ from .domain import ConfluenceDocumentOptions, ConfluencePageID
31
30
  from .extra import path_relative_to
32
- from .mermaid import render_diagram
31
+ from .markdown import markdown_to_html
33
32
  from .metadata import ConfluenceSiteMetadata
34
33
  from .properties import PageError
35
34
  from .scanner import ScannedDocument, Scanner
@@ -101,90 +100,6 @@ def encode_title(text: str) -> str:
101
100
  return quote_plus(text.strip())
102
101
 
103
102
 
104
- def emoji_generator(
105
- index: str,
106
- shortname: str,
107
- alias: Optional[str],
108
- uc: Optional[str],
109
- alt: str,
110
- title: Optional[str],
111
- category: Optional[str],
112
- options: dict[str, Any],
113
- md: markdown.Markdown,
114
- ) -> xml.etree.ElementTree.Element:
115
- """
116
- Custom generator for `pymdownx.emoji`.
117
- """
118
-
119
- name = (alias or shortname).strip(":")
120
- span = xml.etree.ElementTree.Element("span", {"data-emoji-shortname": name})
121
- if uc is not None:
122
- span.attrib["data-emoji-unicode"] = uc
123
-
124
- # convert series of Unicode code point hexadecimal values into characters
125
- span.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
126
- else:
127
- span.text = alt
128
- return span
129
-
130
-
131
- def math_formatter(
132
- source: str,
133
- language: str,
134
- css_class: str,
135
- options: dict[str, Any],
136
- md: markdown.Markdown,
137
- classes: Optional[list[str]] = None,
138
- id_value: str = "",
139
- attrs: Optional[dict[str, str]] = None,
140
- **kwargs: Any,
141
- ) -> str:
142
- """
143
- Custom formatter for language `math` in `pymdownx.superfences`.
144
- """
145
-
146
- if classes is None:
147
- classes = [css_class]
148
- else:
149
- classes.insert(0, css_class)
150
-
151
- html_id = f' id="{id_value}"' if id_value else ""
152
- html_class = ' class="{}"'.format(" ".join(classes))
153
- html_attrs = " " + " ".join(f'{k}="{v}"' for k, v in attrs.items()) if attrs else ""
154
-
155
- return f"<div{html_id}{html_class}{html_attrs}>{source}</div>"
156
-
157
-
158
- def markdown_to_html(content: str) -> str:
159
- return markdown.markdown(
160
- content,
161
- extensions=[
162
- "admonition",
163
- "footnotes",
164
- "markdown.extensions.tables",
165
- "md_in_html",
166
- "pymdownx.arithmatex",
167
- "pymdownx.emoji",
168
- "pymdownx.highlight", # required by `pymdownx.superfences`
169
- "pymdownx.magiclink",
170
- "pymdownx.superfences",
171
- "pymdownx.tilde",
172
- "sane_lists",
173
- ],
174
- extension_configs={
175
- "footnotes": {"BACKLINK_TITLE": ""},
176
- "pymdownx.arithmatex": {"generic": True, "preview": False, "tex_inline_wrap": ["", ""], "tex_block_wrap": ["", ""]},
177
- "pymdownx.emoji": {
178
- "emoji_generator": emoji_generator,
179
- },
180
- "pymdownx.highlight": {
181
- "use_pygments": False,
182
- },
183
- "pymdownx.superfences": {"custom_fences": [{"name": "math", "class": "arithmatex", "format": math_formatter}]},
184
- },
185
- )
186
-
187
-
188
103
  def _elements_from_strings(dtd_path: Path, items: list[str]) -> ET._Element:
189
104
  """
190
105
  Creates a fragment of several XML nodes from their string representation wrapped in a root element.
@@ -333,8 +248,8 @@ def title_to_identifier(title: str) -> str:
333
248
  "Converts a section heading title to a GitHub-style Markdown same-page anchor."
334
249
 
335
250
  s = title.strip().lower()
336
- s = re.sub("[^ A-Za-z0-9]", "", s)
337
- s = s.replace(" ", "-")
251
+ s = re.sub(r"[^\sA-Za-z0-9_\-]", "", s)
252
+ s = re.sub(r"\s+", "-", s)
338
253
  return s
339
254
 
340
255
 
@@ -594,9 +509,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
594
509
  if absolute_path.name.endswith(".drawio.png") or absolute_path.name.endswith(".drawio.svg"):
595
510
  return self._transform_drawio_image(absolute_path, attrs)
596
511
  elif absolute_path.name.endswith(".drawio.xml") or absolute_path.name.endswith(".drawio"):
597
- self.images.append(absolute_path)
598
- image_filename = attachment_name(path_relative_to(absolute_path, self.base_dir))
599
- return self._create_drawio(image_filename, attrs)
512
+ return self._transform_drawio(absolute_path, attrs)
600
513
  else:
601
514
  return self._transform_attached_image(absolute_path, attrs)
602
515
 
@@ -651,10 +564,28 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
651
564
  absolute_path = png_file
652
565
 
653
566
  self.images.append(absolute_path)
654
- return self._create_image(absolute_path, attrs)
567
+ image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
568
+ return self._create_attached_image(image_name, attrs)
569
+
570
+ def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
571
+ "Emits Confluence Storage Format XHTML for a draw.io diagram."
572
+
573
+ if not absolute_path.name.endswith(".drawio.xml") and not absolute_path.name.endswith(".drawio"):
574
+ raise DocumentError("invalid image format; expected: `*.drawio.xml` or `*.drawio`")
575
+
576
+ if self.options.render_drawio:
577
+ image_data = drawio.render_diagram(absolute_path, self.options.diagram_output_format)
578
+ image_hash = hashlib.md5(image_data).hexdigest()
579
+ image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
580
+ self.embedded_images[image_filename] = image_data
581
+ return self._create_attached_image(image_filename, attrs)
582
+ else:
583
+ self.images.append(absolute_path)
584
+ image_filename = attachment_name(path_relative_to(absolute_path, self.base_dir))
585
+ return self._create_drawio(image_filename, attrs)
655
586
 
656
587
  def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
657
- "Emits Confluence Storage Format XHTML for a draw.io image."
588
+ "Emits Confluence Storage Format XHTML for a draw.io diagram embedded in a PNG or SVG image."
658
589
 
659
590
  if not absolute_path.name.endswith(".drawio.png") and not absolute_path.name.endswith(".drawio.svg"):
660
591
  raise DocumentError("invalid image format; expected: `*.drawio.png` or `*.drawio.svg`")
@@ -663,17 +594,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
663
594
  return self._transform_attached_image(absolute_path, attrs)
664
595
  else:
665
596
  # extract embedded editable diagram and upload as *.drawio
666
- image_data = extract_diagram(absolute_path)
597
+ image_data = drawio.extract_diagram(absolute_path)
667
598
  image_filename = attachment_name(path_relative_to(absolute_path.with_suffix(".xml"), self.base_dir))
668
599
  self.embedded_images[image_filename] = image_data
669
600
 
670
601
  return self._create_drawio(image_filename, attrs)
671
602
 
672
- def _create_image(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
603
+ def _create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ET._Element:
673
604
  "An image embedded into the page, linking to an attachment."
674
605
 
675
- image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
676
-
677
606
  attributes: dict[str, Any] = {
678
607
  ET.QName(namespaces["ac"], "align"): "center",
679
608
  ET.QName(namespaces["ac"], "layout"): "center",
@@ -803,21 +732,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
803
732
  "Transforms a Mermaid diagram code block."
804
733
 
805
734
  if self.options.render_mermaid:
806
- image_data = render_diagram(content, self.options.diagram_output_format)
735
+ image_data = mermaid.render_diagram(content, self.options.diagram_output_format)
807
736
  image_hash = hashlib.md5(image_data).hexdigest()
808
737
  image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
809
738
  self.embedded_images[image_filename] = image_data
810
- return AC(
811
- "image",
812
- {
813
- ET.QName(namespaces["ac"], "align"): "center",
814
- ET.QName(namespaces["ac"], "layout"): "center",
815
- },
816
- RI(
817
- "attachment",
818
- {ET.QName(namespaces["ri"], "filename"): image_filename},
819
- ),
820
- )
739
+ return self._create_attached_image(image_filename, ImageAttributes(None, None, None))
821
740
  else:
822
741
  local_id = str(uuid.uuid4())
823
742
  macro_id = str(uuid.uuid4())
@@ -1388,48 +1307,6 @@ class DocumentError(RuntimeError):
1388
1307
  "Raised when a converted Markdown document has an unexpected element or attribute."
1389
1308
 
1390
1309
 
1391
- @dataclass
1392
- class ConfluencePageID:
1393
- page_id: str
1394
-
1395
-
1396
- @dataclass
1397
- class ConfluenceQualifiedID:
1398
- page_id: str
1399
- space_key: str
1400
-
1401
-
1402
- @dataclass
1403
- class ConfluenceDocumentOptions:
1404
- """
1405
- Options that control the generated page content.
1406
-
1407
- :param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
1408
- plain text; when false, raise an exception.
1409
- :param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
1410
- conversion rules for the identifier.
1411
- :param generated_by: Text to use as the generated-by prompt (or `None` to omit a prompt).
1412
- :param root_page_id: Confluence page to assume root page role for publishing a directory of Markdown files.
1413
- :param keep_hierarchy: Whether to maintain source directory structure when exporting to Confluence.
1414
- :param prefer_raster: Whether to choose PNG files over SVG files when available.
1415
- :param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
1416
- :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
1417
- :param diagram_output_format: Target image format for diagrams.
1418
- :param webui_links: When true, convert relative URLs to Confluence Web UI links.
1419
- """
1420
-
1421
- ignore_invalid_url: bool = False
1422
- heading_anchors: bool = False
1423
- generated_by: Optional[str] = "This page has been generated with a tool."
1424
- root_page_id: Optional[ConfluencePageID] = None
1425
- keep_hierarchy: bool = False
1426
- prefer_raster: bool = True
1427
- render_drawio: bool = False
1428
- render_mermaid: bool = False
1429
- diagram_output_format: Literal["png", "svg"] = "png"
1430
- webui_links: bool = False
1431
-
1432
-
1433
1310
  class ConversionError(RuntimeError):
1434
1311
  "Raised when a Markdown document cannot be converted to Confluence Storage Format."
1435
1312
 
@@ -1507,15 +1384,7 @@ class ConfluenceDocument:
1507
1384
  raise ConversionError(path) from ex
1508
1385
 
1509
1386
  converter = ConfluenceStorageFormatConverter(
1510
- ConfluenceConverterOptions(
1511
- ignore_invalid_url=self.options.ignore_invalid_url,
1512
- heading_anchors=self.options.heading_anchors,
1513
- prefer_raster=self.options.prefer_raster,
1514
- render_drawio=self.options.render_drawio,
1515
- render_mermaid=self.options.render_mermaid,
1516
- diagram_output_format=self.options.diagram_output_format,
1517
- webui_links=self.options.webui_links,
1518
- ),
1387
+ ConfluenceConverterOptions(**{field.name: getattr(self.options, field.name) for field in dataclasses.fields(ConfluenceConverterOptions)}),
1519
1388
  path,
1520
1389
  root_dir,
1521
1390
  site_metadata,
md2conf/domain.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2025, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Literal, Optional
11
+
12
+
13
+ @dataclass
14
+ class ConfluencePageID:
15
+ page_id: str
16
+
17
+
18
+ @dataclass
19
+ class ConfluenceDocumentOptions:
20
+ """
21
+ Options that control the generated page content.
22
+
23
+ :param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
24
+ plain text; when false, raise an exception.
25
+ :param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
26
+ conversion rules for the identifier.
27
+ :param generated_by: Text to use as the generated-by prompt (or `None` to omit a prompt).
28
+ :param root_page_id: Confluence page to assume root page role for publishing a directory of Markdown files.
29
+ :param keep_hierarchy: Whether to maintain source directory structure when exporting to Confluence.
30
+ :param prefer_raster: Whether to choose PNG files over SVG files when available.
31
+ :param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
32
+ :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
33
+ :param diagram_output_format: Target image format for diagrams.
34
+ :param webui_links: When true, convert relative URLs to Confluence Web UI links.
35
+ """
36
+
37
+ ignore_invalid_url: bool = False
38
+ heading_anchors: bool = False
39
+ generated_by: Optional[str] = "This page has been generated with a tool."
40
+ root_page_id: Optional[ConfluencePageID] = None
41
+ keep_hierarchy: bool = False
42
+ prefer_raster: bool = True
43
+ render_drawio: bool = False
44
+ render_mermaid: bool = False
45
+ diagram_output_format: Literal["png", "svg"] = "png"
46
+ webui_links: bool = False
md2conf/drawio.py CHANGED
@@ -7,6 +7,11 @@ Copyright 2022-2025, Levente Hunyadi
7
7
  """
8
8
 
9
9
  import base64
10
+ import logging
11
+ import os
12
+ import os.path
13
+ import shutil
14
+ import subprocess
10
15
  import typing
11
16
  import zlib
12
17
  from pathlib import Path
@@ -15,6 +20,8 @@ from urllib.parse import unquote_to_bytes
15
20
 
16
21
  import lxml.etree as ET
17
22
 
23
+ LOGGER = logging.getLogger(__name__)
24
+
18
25
 
19
26
  class DrawioError(ValueError):
20
27
  """
@@ -220,3 +227,45 @@ def extract_diagram(path: Path) -> bytes:
220
227
  raise DrawioError(f"unrecognized file type for {path.name}")
221
228
 
222
229
  return ET.tostring(root, encoding="utf8", method="xml")
230
+
231
+
232
+ def render_diagram(source: Path, output_format: typing.Literal["png", "svg"] = "png") -> bytes:
233
+ "Generates a PNG or SVG image from a draw.io diagram source."
234
+
235
+ executable = shutil.which("draw.io")
236
+ if executable is None:
237
+ raise DrawioError("draw.io executable not found")
238
+
239
+ target = f"tmp_drawio.{output_format}"
240
+
241
+ cmd = [executable, "--export", "--format", output_format, "--output", target]
242
+ if output_format == "png":
243
+ cmd.extend(["--scale", "2", "--transparent"])
244
+ elif output_format == "svg":
245
+ cmd.append("--embed-svg-images")
246
+ cmd.append(str(source))
247
+
248
+ LOGGER.debug("Executing: %s", " ".join(cmd))
249
+ try:
250
+ proc = subprocess.Popen(
251
+ cmd,
252
+ stdout=subprocess.PIPE,
253
+ stderr=subprocess.PIPE,
254
+ text=False,
255
+ )
256
+ stdout, stderr = proc.communicate()
257
+ if proc.returncode:
258
+ messages = [f"failed to convert draw.io diagram; exit code: {proc.returncode}"]
259
+ console_output = stdout.decode("utf-8")
260
+ if console_output:
261
+ messages.append(f"output:\n{console_output}")
262
+ console_error = stderr.decode("utf-8")
263
+ if console_error:
264
+ messages.append(f"error:\n{console_error}")
265
+ raise DrawioError("\n".join(messages))
266
+ with open(target, "rb") as f:
267
+ return f.read()
268
+
269
+ finally:
270
+ if os.path.exists(target):
271
+ os.remove(target)
md2conf/local.py CHANGED
@@ -11,7 +11,8 @@ import os
11
11
  from pathlib import Path
12
12
  from typing import Optional
13
13
 
14
- from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
14
+ from .converter import ConfluenceDocument
15
+ from .domain import ConfluenceDocumentOptions, ConfluencePageID
15
16
  from .extra import override
16
17
  from .metadata import ConfluencePageMetadata, ConfluenceSiteMetadata
17
18
  from .processor import Converter, DocumentNode, Processor, ProcessorFactory
@@ -77,10 +78,14 @@ class LocalProcessor(Processor):
77
78
  """
78
79
 
79
80
  content = document.xhtml()
80
- out_path = self.out_dir / path.relative_to(self.root_dir).with_suffix(".csf")
81
- os.makedirs(out_path.parent, exist_ok=True)
82
- with open(out_path, "w", encoding="utf-8") as f:
81
+ csf_path = self.out_dir / path.relative_to(self.root_dir).with_suffix(".csf")
82
+ csf_dir = csf_path.parent
83
+ os.makedirs(csf_dir, exist_ok=True)
84
+ with open(csf_path, "w", encoding="utf-8") as f:
83
85
  f.write(content)
86
+ for name, data in document.embedded_images.items():
87
+ with open(csf_dir / name, "wb") as f:
88
+ f.write(data)
84
89
 
85
90
 
86
91
  class LocalProcessorFactory(ProcessorFactory):
md2conf/markdown.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2025, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ import xml.etree.ElementTree
10
+ from typing import Any, Optional
11
+
12
+ import markdown
13
+
14
+
15
+ def _emoji_generator(
16
+ index: str,
17
+ shortname: str,
18
+ alias: Optional[str],
19
+ uc: Optional[str],
20
+ alt: str,
21
+ title: Optional[str],
22
+ category: Optional[str],
23
+ options: dict[str, Any],
24
+ md: markdown.Markdown,
25
+ ) -> xml.etree.ElementTree.Element:
26
+ """
27
+ Custom generator for `pymdownx.emoji`.
28
+ """
29
+
30
+ name = (alias or shortname).strip(":")
31
+ span = xml.etree.ElementTree.Element("span", {"data-emoji-shortname": name})
32
+ if uc is not None:
33
+ span.attrib["data-emoji-unicode"] = uc
34
+
35
+ # convert series of Unicode code point hexadecimal values into characters
36
+ span.text = "".join(chr(int(item, base=16)) for item in uc.split("-"))
37
+ else:
38
+ span.text = alt
39
+ return span
40
+
41
+
42
+ def _math_formatter(
43
+ source: str,
44
+ language: str,
45
+ css_class: str,
46
+ options: dict[str, Any],
47
+ md: markdown.Markdown,
48
+ classes: Optional[list[str]] = None,
49
+ id_value: str = "",
50
+ attrs: Optional[dict[str, str]] = None,
51
+ **kwargs: Any,
52
+ ) -> str:
53
+ """
54
+ Custom formatter for language `math` in `pymdownx.superfences`.
55
+ """
56
+
57
+ if classes is None:
58
+ classes = [css_class]
59
+ else:
60
+ classes.insert(0, css_class)
61
+
62
+ html_id = f' id="{id_value}"' if id_value else ""
63
+ html_class = ' class="{}"'.format(" ".join(classes))
64
+ html_attrs = " " + " ".join(f'{k}="{v}"' for k, v in attrs.items()) if attrs else ""
65
+
66
+ return f"<div{html_id}{html_class}{html_attrs}>{source}</div>"
67
+
68
+
69
+ _CONVERTER = markdown.Markdown(
70
+ extensions=[
71
+ "admonition",
72
+ "footnotes",
73
+ "markdown.extensions.tables",
74
+ "md_in_html",
75
+ "pymdownx.arithmatex",
76
+ "pymdownx.emoji",
77
+ "pymdownx.highlight", # required by `pymdownx.superfences`
78
+ "pymdownx.magiclink",
79
+ "pymdownx.superfences",
80
+ "pymdownx.tilde",
81
+ "sane_lists",
82
+ ],
83
+ extension_configs={
84
+ "footnotes": {"BACKLINK_TITLE": ""},
85
+ "pymdownx.arithmatex": {"generic": True, "preview": False, "tex_inline_wrap": ["", ""], "tex_block_wrap": ["", ""]},
86
+ "pymdownx.emoji": {
87
+ "emoji_generator": _emoji_generator,
88
+ },
89
+ "pymdownx.highlight": {
90
+ "use_pygments": False,
91
+ },
92
+ "pymdownx.superfences": {"custom_fences": [{"name": "math", "class": "arithmatex", "format": _math_formatter}]},
93
+ },
94
+ )
95
+
96
+
97
+ def markdown_to_html(content: str) -> str:
98
+ """
99
+ Converts a Markdown document into XHTML with Python-Markdown.
100
+
101
+ :param content: Markdown input as a string.
102
+ :returns: XHTML output as a string.
103
+ :see: https://python-markdown.github.io/
104
+ """
105
+
106
+ _CONVERTER.reset()
107
+ html = _CONVERTER.convert(content)
108
+ return html
md2conf/processor.py CHANGED
@@ -14,7 +14,8 @@ from pathlib import Path
14
14
  from typing import Iterable, Optional
15
15
 
16
16
  from .collection import ConfluencePageCollection
17
- from .converter import ConfluenceDocument, ConfluenceDocumentOptions, ConfluencePageID
17
+ from .converter import ConfluenceDocument
18
+ from .domain import ConfluenceDocumentOptions, ConfluencePageID
18
19
  from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
19
20
  from .metadata import ConfluenceSiteMetadata
20
21
  from .properties import ArgumentError