markdown-to-confluence 0.1.12__py3-none-any.whl → 0.2.0__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.1
2
2
  Name: markdown-to-confluence
3
- Version: 0.1.12
3
+ Version: 0.2.0
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Home-page: https://github.com/hunyadi/md2conf
6
6
  Author: Levente Hunyadi
@@ -21,13 +21,13 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.8
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: lxml >=5.2
25
- Requires-Dist: types-lxml >=2024.4.14
26
- Requires-Dist: markdown >=3.6
27
- Requires-Dist: types-markdown >=3.6
28
- Requires-Dist: pymdown-extensions >=10.8
29
- Requires-Dist: requests >=2.32
30
- Requires-Dist: types-requests >=2.32
24
+ Requires-Dist: lxml>=5.3
25
+ Requires-Dist: types-lxml>=2024.8.7
26
+ Requires-Dist: markdown>=3.6
27
+ Requires-Dist: types-markdown>=3.6
28
+ Requires-Dist: pymdown-extensions>=10.9
29
+ Requires-Dist: requests>=2.32
30
+ Requires-Dist: types-requests>=2.32
31
31
 
32
32
  # Publish Markdown files to Confluence wiki
33
33
 
@@ -45,12 +45,29 @@ This Python package
45
45
 
46
46
  * Sections and subsections
47
47
  * Text with **bold**, *italic*, `monospace`, <ins>underline</ins> and ~~strikethrough~~
48
- * Link to [external locations](http://example.com/)
48
+ * Link to [sections on the same page](#getting-started) or [external locations](http://example.com/)
49
49
  * Ordered and unordered lists
50
50
  * Code blocks (e.g. Python, JSON, XML)
51
51
  * Image references (uploaded as Confluence page attachments)
52
- * [Table of Contents](https://docs.gitlab.com/ee/user/markdown.html#table-of-contents)
53
- * [Admonitions](https://python-markdown.github.io/extensions/admonition/) (converted into *info*, *tip*, *note* and *warning* Confluence panels)
52
+ * Tables
53
+ * [Table of contents](https://docs.gitlab.com/ee/user/markdown.html#table-of-contents)
54
+ * [Admonitions](https://python-markdown.github.io/extensions/admonition/) and [alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
55
+ * [Collapsed sections](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections)
56
+ * [Mermaid diagrams](https://mermaid.live/) in code blocks (converted to images)
57
+
58
+ ## Installation
59
+
60
+ Install the core package from PyPI:
61
+
62
+ ```sh
63
+ pip install markdown-to-confluence
64
+ ```
65
+
66
+ Converting code blocks of Mermaid diagrams into Confluence image attachments requires [mermaid-cli](https://github.com/mermaid-js/mermaid-cli):
67
+
68
+ ```sh
69
+ npm install -g @mermaid-js/mermaid-cli
70
+ ```
54
71
 
55
72
  ## Getting started
56
73
 
@@ -131,7 +148,7 @@ Alternatively, use the `--generated-by GENERATED_BY` option. The tag takes prece
131
148
 
132
149
  You execute the command-line tool `md2conf` to synchronize the Markdown file with Confluence:
133
150
 
134
- ```console
151
+ ```sh
135
152
  $ python3 -m md2conf sample/example.md
136
153
  ```
137
154
 
@@ -139,14 +156,15 @@ Use the `--help` switch to get a full list of supported command-line options:
139
156
 
140
157
  ```console
141
158
  $ python3 -m md2conf --help
142
- usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE] [--generated-by GENERATED_BY]
143
- [--no-generated-by] [--ignore-invalid-url] [--local]
159
+ usage: md2conf [-h] [-d DOMAIN] [-p PATH] [-u USERNAME] [-a APIKEY] [-s SPACE] [-l {debug,info,warning,error,critical}] [-r ROOT_PAGE]
160
+ [--generated-by GENERATED_BY] [--no-generated-by] [--render-mermaid] [--no-render-mermaid]
161
+ [--render-mermaid-format {png,svg}] [--heading-anchors] [--ignore-invalid-url] [--local]
144
162
  mdpath
145
163
 
146
164
  positional arguments:
147
165
  mdpath Path to Markdown file or directory to convert and publish.
148
166
 
149
- optional arguments:
167
+ options:
150
168
  -h, --help show this help message and exit
151
169
  -d DOMAIN, --domain DOMAIN
152
170
  Confluence organization domain.
@@ -163,6 +181,41 @@ optional arguments:
163
181
  --generated-by GENERATED_BY
164
182
  Add prompt to pages (default: 'This page has been generated with a tool.').
165
183
  --no-generated-by Do not add 'generated by a tool' prompt to pages.
184
+ --render-mermaid Render Mermaid diagrams as image files and add as attachments.
185
+ --no-render-mermaid Inline Mermaid diagram in Confluence page.
186
+ --render-mermaid-format {png,svg}
187
+ Format for rendering Mermaid diagrams (default: 'png').
188
+ --heading-anchors Place an anchor at each section heading with GitHub-style same-page identifiers.
166
189
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
167
190
  --local Write XHTML-based Confluence Storage Format files locally without invoking Confluence API.
168
191
  ```
192
+
193
+ ### Using the docker container
194
+
195
+ You can run the docker container via `docker run` or via `Dockerfile`. Either can accept the environment variables or arguments similar to the Python options. The final argument `./` corresponds to `mdpath` in the command-line utility.
196
+
197
+ ```sh
198
+ docker run --rm --name md2conf hunyadi/md2conf -d instructure.atlassian.net -u levente.hunyadi@instructure.com -a 0123456789abcdef -s DAP ./
199
+ ```
200
+
201
+ Note that the entry point for the docker container's base image is `ENTRYPOINT ["python3", "-m", "md2conf"]`.
202
+
203
+ ```Dockerfile
204
+ FROM hunyadi/md2conf:latest
205
+
206
+ ENV CONFLUENCE_DOMAIN='instructure.atlassian.net'
207
+ ENV CONFLUENCE_PATH='/wiki/'
208
+ ENV CONFLUENCE_USER_NAME='levente.hunyadi@instructure.com'
209
+ ENV CONFLUENCE_API_KEY='0123456789abcdef'
210
+ ENV CONFLUENCE_SPACE_KEY='DAP'
211
+
212
+ CMD ["./"]
213
+ ```
214
+
215
+ Alternatively,
216
+
217
+ ```Dockerfile
218
+ FROM hunyadi/md2conf:latest
219
+
220
+ CMD ["-d", "instructure.atlassian.net", "-u", "levente.hunyadi@instructure.com", "-a", "0123456789abcdef", "-s", "DAP", "./"]
221
+ ```
@@ -0,0 +1,17 @@
1
+ md2conf/__init__.py,sha256=1KRpqiilQTkQz-oL8-HFPnI_6_3-_H0dq-SxQxDw56s,402
2
+ md2conf/__main__.py,sha256=tWMEA_spxUTNNgViHtjsA85NzJixX-0G2zCq8BO3y_E,5230
3
+ md2conf/api.py,sha256=Oc4FAQBNs85U8s-lbY0XwLBUcjm3Sd0_W59N4H3XAnE,15768
4
+ md2conf/application.py,sha256=NnF84-cdW2cZUbU6VeHvuEg6g5NL5M9o2cpOSU7uv7o,5548
5
+ md2conf/converter.py,sha256=XY7D8zpsVS7_PZzywciQ5YT2SHH5t1udPU5s2aPsmqs,27040
6
+ md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
+ md2conf/mermaid.py,sha256=3zawPXHXkCDhEK-WNtCH-gTqsLBDRzLrmlSo8ZW-Ii8,1371
8
+ md2conf/processor.py,sha256=3JZkbFtMjbtnQLEm6wFum96ldjZ9xNJuL8JjFadyGmg,3084
9
+ md2conf/properties.py,sha256=oXvtPssbougM1BTE9ytcD_1Yjc3nd7DDSHqEr0QoZAU,1811
10
+ md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ markdown_to_confluence-0.2.0.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
12
+ markdown_to_confluence-0.2.0.dist-info/METADATA,sha256=nxwG4F2TX1do0lk38BCFIUMwqv6y2edErd2_5M-4la4,10023
13
+ markdown_to_confluence-0.2.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
14
+ markdown_to_confluence-0.2.0.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
15
+ markdown_to_confluence-0.2.0.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
16
+ markdown_to_confluence-0.2.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
17
+ markdown_to_confluence-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.1.0)
2
+ Generator: setuptools (75.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
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.1.12"
8
+ __version__ = "0.2.0"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2024, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -2,7 +2,6 @@ import argparse
2
2
  import logging
3
3
  import os.path
4
4
  import sys
5
- import typing
6
5
  from pathlib import Path
7
6
  from typing import Optional
8
7
 
@@ -24,6 +23,7 @@ class Arguments(argparse.Namespace):
24
23
  space: str
25
24
  loglevel: str
26
25
  ignore_invalid_url: bool
26
+ heading_anchors: bool
27
27
  generated_by: Optional[str]
28
28
 
29
29
 
@@ -52,7 +52,7 @@ def main() -> None:
52
52
  "-l",
53
53
  "--loglevel",
54
54
  choices=[
55
- typing.cast(str, logging.getLevelName(level)).lower()
55
+ logging.getLevelName(level).lower()
56
56
  for level in (
57
57
  logging.DEBUG,
58
58
  logging.INFO,
@@ -81,6 +81,32 @@ def main() -> None:
81
81
  const=None,
82
82
  help="Do not add 'generated by a tool' prompt to pages.",
83
83
  )
84
+ parser.add_argument(
85
+ "--render-mermaid",
86
+ dest="render_mermaid",
87
+ action="store_true",
88
+ default=True,
89
+ help="Render Mermaid diagrams as image files and add as attachments.",
90
+ )
91
+ parser.add_argument(
92
+ "--no-render-mermaid",
93
+ dest="render_mermaid",
94
+ action="store_false",
95
+ help="Inline Mermaid diagram in Confluence page.",
96
+ )
97
+ parser.add_argument(
98
+ "--render-mermaid-format",
99
+ dest="diagram_output_format",
100
+ choices=["png", "svg"],
101
+ default="png",
102
+ help="Format for rendering Mermaid diagrams (default: 'png').",
103
+ )
104
+ parser.add_argument(
105
+ "--heading-anchors",
106
+ action="store_true",
107
+ default=False,
108
+ help="Place an anchor at each section heading with GitHub-style same-page identifiers.",
109
+ )
84
110
  parser.add_argument(
85
111
  "--ignore-invalid-url",
86
112
  action="store_true",
@@ -107,9 +133,12 @@ def main() -> None:
107
133
  )
108
134
 
109
135
  options = ConfluenceDocumentOptions(
136
+ heading_anchors=args.heading_anchors,
110
137
  ignore_invalid_url=args.ignore_invalid_url,
111
138
  generated_by=args.generated_by,
112
139
  root_page_id=args.root_page,
140
+ render_mermaid=args.render_mermaid,
141
+ diagram_output_format=args.diagram_output_format,
113
142
  )
114
143
  properties = ConfluenceProperties(
115
144
  args.domain, args.path, args.username, args.apikey, args.space
md2conf/api.py CHANGED
@@ -1,3 +1,4 @@
1
+ import io
1
2
  import json
2
3
  import logging
3
4
  import mimetypes
@@ -177,7 +178,8 @@ class ConfluenceSession:
177
178
  extensions = typing.cast(Dict[str, JsonType], result["extensions"])
178
179
  media_type = typing.cast(str, extensions["mediaType"])
179
180
  file_size = typing.cast(int, extensions["fileSize"])
180
- comment = typing.cast(str, extensions["comment"])
181
+ comment = extensions.get("comment", "")
182
+ comment = typing.cast(str, comment)
181
183
  return ConfluenceAttachment(id, media_type, file_size, comment)
182
184
 
183
185
  def upload_attachment(
@@ -185,6 +187,7 @@ class ConfluenceSession:
185
187
  page_id: str,
186
188
  attachment_path: Path,
187
189
  attachment_name: str,
190
+ raw_data: Optional[bytes] = None,
188
191
  comment: Optional[str] = None,
189
192
  *,
190
193
  space_key: Optional[str] = None,
@@ -192,7 +195,7 @@ class ConfluenceSession:
192
195
  ) -> None:
193
196
  content_type = mimetypes.guess_type(attachment_path, strict=True)[0]
194
197
 
195
- if not attachment_path.is_file():
198
+ if not raw_data and not attachment_path.is_file():
196
199
  raise ConfluenceError(f"file not found: {attachment_path}")
197
200
 
198
201
  try:
@@ -200,9 +203,14 @@ class ConfluenceSession:
200
203
  page_id, attachment_name, space_key=space_key
201
204
  )
202
205
 
203
- if not force and attachment.file_size == attachment_path.stat().st_size:
204
- LOGGER.info("Up-to-date attachment: %s", attachment_name)
205
- return
206
+ if not raw_data:
207
+ if not force and attachment.file_size == attachment_path.stat().st_size:
208
+ LOGGER.info("Up-to-date attachment: %s", attachment_name)
209
+ return
210
+ else:
211
+ if not force and attachment.file_size == len(raw_data):
212
+ LOGGER.info("Up-to-date embedded image: %s", attachment_name)
213
+ return
206
214
 
207
215
  id = removeprefix(attachment.id, "att")
208
216
  path = f"/content/{page_id}/child/attachment/{id}/data"
@@ -212,17 +220,36 @@ class ConfluenceSession:
212
220
 
213
221
  url = self._build_url(path)
214
222
 
215
- with open(attachment_path, "rb") as attachment_file:
223
+ if not raw_data:
224
+ with open(attachment_path, "rb") as attachment_file:
225
+ file_to_upload = {
226
+ "comment": comment,
227
+ "file": (
228
+ attachment_name, # will truncate path component
229
+ attachment_file,
230
+ content_type,
231
+ {"Expires": "0"},
232
+ ),
233
+ }
234
+ LOGGER.info("Uploading attachment: %s", attachment_name)
235
+ response = self.session.post(
236
+ url,
237
+ files=file_to_upload, # type: ignore
238
+ headers={"X-Atlassian-Token": "no-check"},
239
+ )
240
+ else:
241
+ LOGGER.info("Uploading raw data: %s", attachment_name)
242
+
216
243
  file_to_upload = {
217
244
  "comment": comment,
218
245
  "file": (
219
246
  attachment_name, # will truncate path component
220
- attachment_file,
247
+ io.BytesIO(raw_data), # type: ignore
221
248
  content_type,
222
249
  {"Expires": "0"},
223
250
  ),
224
251
  }
225
- LOGGER.info("Uploading attachment: %s", attachment_name)
252
+
226
253
  response = self.session.post(
227
254
  url,
228
255
  files=file_to_upload, # type: ignore
md2conf/application.py CHANGED
@@ -94,7 +94,7 @@ class Application:
94
94
  """
95
95
 
96
96
  # parse file
97
- with open(absolute_path, "r") as f:
97
+ with open(absolute_path, "r", encoding="utf-8") as f:
98
98
  document = f.read()
99
99
 
100
100
  qualified_id, document = extract_qualified_id(document)
@@ -131,9 +131,20 @@ class Application:
131
131
  )
132
132
 
133
133
  def _update_document(self, document: ConfluenceDocument, base_path: Path) -> None:
134
+
134
135
  for image in document.images:
135
136
  self.api.upload_attachment(
136
- document.id.page_id, base_path / image, attachment_name(image), ""
137
+ document.id.page_id,
138
+ base_path / image,
139
+ attachment_name(image),
140
+ )
141
+
142
+ for image, data in document.embedded_images.items():
143
+ self.api.upload_attachment(
144
+ document.id.page_id,
145
+ Path("EMB") / image,
146
+ attachment_name(image),
147
+ raw_data=data,
137
148
  )
138
149
 
139
150
  content = document.xhtml()
@@ -147,7 +158,7 @@ class Application:
147
158
  page_id: str,
148
159
  space_key: Optional[str],
149
160
  ) -> None:
150
- with open(path, "w") as file:
161
+ with open(path, "w", encoding="utf-8") as file:
151
162
  file.write(f"<!-- confluence-page-id: {page_id} -->\n")
152
163
  if space_key:
153
164
  file.write(f"<!-- confluence-space-key: {space_key} -->\n")
md2conf/converter.py CHANGED
@@ -1,17 +1,23 @@
1
+ # mypy: disable-error-code="dict-item"
2
+
3
+ import hashlib
1
4
  import importlib.resources as resources
2
5
  import logging
3
6
  import os.path
4
7
  import pathlib
5
8
  import re
6
9
  import sys
10
+ import uuid
7
11
  from dataclasses import dataclass
8
- from typing import Dict, List, Optional, Tuple
12
+ from typing import Dict, List, Literal, Optional, Tuple
9
13
  from urllib.parse import ParseResult, urlparse, urlunparse
10
14
 
11
15
  import lxml.etree as ET
12
16
  import markdown
13
17
  from lxml.builder import ElementMaker
14
18
 
19
+ from . import mermaid
20
+
15
21
  namespaces = {
16
22
  "ac": "http://atlassian.com/content",
17
23
  "ri": "http://atlassian.com/resource/identifier",
@@ -19,7 +25,6 @@ namespaces = {
19
25
  for key, value in namespaces.items():
20
26
  ET.register_namespace(key, value)
21
27
 
22
-
23
28
  HTML = ElementMaker()
24
29
  AC = ElementMaker(namespace=namespaces["ac"])
25
30
  RI = ElementMaker(namespace=namespaces["ri"])
@@ -51,6 +56,7 @@ def markdown_to_html(content: str) -> str:
51
56
  "pymdownx.magiclink",
52
57
  "pymdownx.tilde",
53
58
  "sane_lists",
59
+ "md_in_html",
54
60
  ],
55
61
  )
56
62
 
@@ -100,6 +106,10 @@ def elements_from_strings(items: List[str]) -> ET._Element:
100
106
  return _elements_from_strings(dtd_path, items)
101
107
 
102
108
 
109
+ def elements_from_string(content: str) -> ET._Element:
110
+ return elements_from_strings([content])
111
+
112
+
103
113
  _languages = [
104
114
  "abap",
105
115
  "actionscript3",
@@ -139,6 +149,7 @@ _languages = [
139
149
  "kotlin",
140
150
  "livescript",
141
151
  "lua",
152
+ "mermaid",
142
153
  "mathematica",
143
154
  "matlab",
144
155
  "objectivec",
@@ -192,6 +203,8 @@ class ConfluencePageMetadata:
192
203
 
193
204
  class NodeVisitor:
194
205
  def visit(self, node: ET._Element) -> None:
206
+ "Recursively visits all descendants of this node."
207
+
195
208
  if len(node) < 1:
196
209
  return
197
210
 
@@ -207,6 +220,15 @@ class NodeVisitor:
207
220
  pass
208
221
 
209
222
 
223
+ def title_to_identifier(title: str) -> str:
224
+ "Converts a section heading title to a GitHub-style Markdown same-page anchor."
225
+
226
+ s = title.strip().lower()
227
+ s = re.sub("[^ A-Za-z0-9]", "", s)
228
+ s = s.replace(" ", "-")
229
+ return s
230
+
231
+
210
232
  @dataclass
211
233
  class ConfluenceConverterOptions:
212
234
  """
@@ -214,9 +236,16 @@ class ConfluenceConverterOptions:
214
236
 
215
237
  :param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
216
238
  plain text; when false, raise an exception.
239
+ :param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
240
+ conversion rules for the identifier.
241
+ :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
242
+ :param diagram_output_format: Target image format for diagrams.
217
243
  """
218
244
 
219
245
  ignore_invalid_url: bool = False
246
+ heading_anchors: bool = False
247
+ render_mermaid: bool = False
248
+ diagram_output_format: Literal["png", "svg"] = "png"
220
249
 
221
250
 
222
251
  class ConfluenceStorageFormatConverter(NodeVisitor):
@@ -227,6 +256,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
227
256
  base_path: pathlib.Path
228
257
  links: List[str]
229
258
  images: List[str]
259
+ embedded_images: Dict[str, bytes]
230
260
  page_metadata: Dict[pathlib.Path, ConfluencePageMetadata]
231
261
 
232
262
  def __init__(
@@ -241,8 +271,33 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
241
271
  self.base_path = path.parent
242
272
  self.links = []
243
273
  self.images = []
274
+ self.embedded_images = {}
244
275
  self.page_metadata = page_metadata
245
276
 
277
+ def _transform_heading(self, heading: ET._Element) -> None:
278
+ title = "".join(heading.itertext()).strip()
279
+
280
+ for e in heading:
281
+ self.visit(e)
282
+
283
+ anchor = AC(
284
+ "structured-macro",
285
+ {
286
+ ET.QName(namespaces["ac"], "name"): "anchor",
287
+ ET.QName(namespaces["ac"], "schema-version"): "1",
288
+ },
289
+ AC(
290
+ "parameter",
291
+ {ET.QName(namespaces["ac"], "name"): ""},
292
+ title_to_identifier(title),
293
+ ),
294
+ )
295
+
296
+ # insert anchor as first child, pushing any text nodes
297
+ heading.insert(0, anchor)
298
+ anchor.tail = heading.text
299
+ heading.text = None
300
+
246
301
  def _transform_link(self, anchor: ET._Element) -> None:
247
302
  url = anchor.attrib["href"]
248
303
  if is_absolute_url(url):
@@ -344,20 +399,89 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
344
399
  language = "none"
345
400
  content: str = code.text or ""
346
401
  content = content.rstrip()
402
+
403
+ if language == "mermaid":
404
+ return self._transform_mermaid(content)
405
+
347
406
  return AC(
348
407
  "structured-macro",
349
408
  {
350
409
  ET.QName(namespaces["ac"], "name"): "code",
351
410
  ET.QName(namespaces["ac"], "schema-version"): "1",
352
411
  },
353
- AC("parameter", {ET.QName(namespaces["ac"], "name"): "theme"}, "Midnight"),
354
- AC("parameter", {ET.QName(namespaces["ac"], "name"): "language"}, language),
355
412
  AC(
356
- "parameter", {ET.QName(namespaces["ac"], "name"): "linenumbers"}, "true"
413
+ "parameter",
414
+ {ET.QName(namespaces["ac"], "name"): "theme"},
415
+ "Midnight",
416
+ ),
417
+ AC(
418
+ "parameter",
419
+ {ET.QName(namespaces["ac"], "name"): "language"},
420
+ language,
421
+ ),
422
+ AC(
423
+ "parameter",
424
+ {ET.QName(namespaces["ac"], "name"): "linenumbers"},
425
+ "true",
357
426
  ),
358
427
  AC("plain-text-body", ET.CDATA(content)),
359
428
  )
360
429
 
430
+ def _transform_mermaid(self, content: str) -> ET._Element:
431
+ "Transforms a Mermaid diagram code block."
432
+
433
+ if self.options.render_mermaid:
434
+ image_data = mermaid.render(content, self.options.diagram_output_format)
435
+ image_hash = hashlib.md5(image_data).hexdigest()
436
+ image_filename = attachment_name(
437
+ f"embedded_{image_hash}.{self.options.diagram_output_format}"
438
+ )
439
+ self.embedded_images[image_filename] = image_data
440
+ return AC(
441
+ "image",
442
+ {
443
+ ET.QName(namespaces["ac"], "align"): "center",
444
+ ET.QName(namespaces["ac"], "layout"): "center",
445
+ },
446
+ RI(
447
+ "attachment",
448
+ {ET.QName(namespaces["ri"], "filename"): image_filename},
449
+ ),
450
+ )
451
+ else:
452
+ local_id = str(uuid.uuid4())
453
+ macro_id = str(uuid.uuid4())
454
+ return AC(
455
+ "structured-macro",
456
+ {
457
+ ET.QName(namespaces["ac"], "name"): "macro-diagram",
458
+ ET.QName(namespaces["ac"], "schema-version"): "1",
459
+ ET.QName(namespaces["ac"], "data-layout"): "default",
460
+ ET.QName(namespaces["ac"], "local-id"): local_id,
461
+ ET.QName(namespaces["ac"], "macro-id"): macro_id,
462
+ },
463
+ AC(
464
+ "parameter",
465
+ {ET.QName(namespaces["ac"], "name"): "sourceType"},
466
+ "MacroBody",
467
+ ),
468
+ AC(
469
+ "parameter",
470
+ {ET.QName(namespaces["ac"], "name"): "attachmentPageId"},
471
+ ),
472
+ AC(
473
+ "parameter",
474
+ {ET.QName(namespaces["ac"], "name"): "syntax"},
475
+ "Mermaid",
476
+ ),
477
+ AC(
478
+ "parameter",
479
+ {ET.QName(namespaces["ac"], "name"): "attachmentId"},
480
+ ),
481
+ AC("parameter", {ET.QName(namespaces["ac"], "name"): "url"}),
482
+ AC("plain-text-body", ET.CDATA(content)),
483
+ )
484
+
361
485
  def _transform_toc(self, code: ET._Element) -> ET._Element:
362
486
  return AC(
363
487
  "structured-macro",
@@ -371,10 +495,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
371
495
 
372
496
  def _transform_admonition(self, elem: ET._Element) -> ET._Element:
373
497
  """
374
- Creates an info, tip, note or warning panel.
498
+ Creates an info, tip, note or warning panel from a Markdown admonition.
375
499
 
376
- Transforms [Python-Markdown admonition](https://python-markdown.github.io/extensions/admonition/) syntax
377
- into Confluence structured macro syntax.
500
+ Transforms [Python-Markdown admonition](https://python-markdown.github.io/extensions/admonition/)
501
+ syntax into one of the Confluence structured macros *info*, *tip*, *note*, or *warning*.
378
502
  """
379
503
 
380
504
  # <div class="admonition note">
@@ -401,7 +525,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
401
525
  AC(
402
526
  "parameter",
403
527
  {ET.QName(namespaces["ac"], "name"): "title"},
404
- elem[0].text,
528
+ elem[0].text or "",
405
529
  ),
406
530
  AC("rich-text-body", {}, *list(elem[1:])),
407
531
  ]
@@ -417,6 +541,87 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
417
541
  *content,
418
542
  )
419
543
 
544
+ def _transform_alert(self, elem: ET._Element) -> ET._Element:
545
+ """
546
+ Creates an info, tip, note or warning panel from a GitHub alert.
547
+
548
+ Transforms
549
+ [GitHub alert](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) # noqa: E501 # no way to make this link shorter
550
+ syntax into one of the Confluence structured macros *info*, *tip*, *note*, or *warning*.
551
+ """
552
+
553
+ pattern = re.compile(r"^\[!([A-Z]+)\]\s*")
554
+
555
+ content = elem[0]
556
+ if content.text is None:
557
+ raise DocumentError("empty content")
558
+
559
+ match = pattern.match(content.text)
560
+ if match is None:
561
+ raise DocumentError("not an alert")
562
+ alert = match.group(1)
563
+
564
+ if alert == "NOTE":
565
+ class_name = "note"
566
+ elif alert == "TIP":
567
+ class_name = "tip"
568
+ elif alert == "IMPORTANT":
569
+ class_name = "tip"
570
+ elif alert == "WARNING":
571
+ class_name = "warning"
572
+ elif alert == "CAUTION":
573
+ class_name = "warning"
574
+ else:
575
+ raise DocumentError(f"unsupported alert: {alert}")
576
+
577
+ for e in elem:
578
+ self.visit(e)
579
+
580
+ content.text = pattern.sub("", content.text, count=1)
581
+ return AC(
582
+ "structured-macro",
583
+ {
584
+ ET.QName(namespaces["ac"], "name"): class_name,
585
+ ET.QName(namespaces["ac"], "schema-version"): "1",
586
+ },
587
+ AC("rich-text-body", {}, *list(elem)),
588
+ )
589
+
590
+ def _transform_section(self, elem: ET._Element) -> ET._Element:
591
+ """
592
+ Creates a collapsed section.
593
+
594
+ Transforms
595
+ [GitHub collapsed section](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections) # noqa: E501 # no way to make this link shorter
596
+ syntax into the Confluence structured macro *expand*.
597
+ """
598
+
599
+ if elem[0].tag != "summary":
600
+ raise DocumentError(
601
+ "expected: `<summary>` as first direct child of `<details>`"
602
+ )
603
+ if elem[0].tail is not None:
604
+ raise DocumentError('expected: attribute `markdown="1"` on `<details>`')
605
+
606
+ summary = "".join(elem[0].itertext()).strip()
607
+ elem.remove(elem[0])
608
+
609
+ self.visit(elem)
610
+
611
+ return AC(
612
+ "structured-macro",
613
+ {
614
+ ET.QName(namespaces["ac"], "name"): "expand",
615
+ ET.QName(namespaces["ac"], "schema-version"): "1",
616
+ },
617
+ AC(
618
+ "parameter",
619
+ {ET.QName(namespaces["ac"], "name"): "title"},
620
+ summary,
621
+ ),
622
+ AC("rich-text-body", {}, *list(elem)),
623
+ )
624
+
420
625
  def transform(self, child: ET._Element) -> Optional[ET._Element]:
421
626
  # normalize line breaks to regular space in element text
422
627
  if child.text:
@@ -426,6 +631,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
426
631
  tail: str = child.tail
427
632
  child.tail = tail.replace("\n", " ")
428
633
 
634
+ if self.options.heading_anchors:
635
+ # <h1>...</h1>
636
+ # <h2>...</h2> ...
637
+ if re.match(r"^h[1-6]$", child.tag, flags=re.IGNORECASE) is not None:
638
+ self._transform_heading(child)
639
+ return None
640
+
429
641
  # <p><img src="..." /></p>
430
642
  if child.tag == "p" and len(child) == 1 and child[0].tag == "img":
431
643
  return self._transform_image(child[0])
@@ -448,6 +660,26 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
448
660
  elif child.tag == "div" and "admonition" in child.attrib.get("class", ""):
449
661
  return self._transform_admonition(child)
450
662
 
663
+ # Alerts in GitHub
664
+ # <blockquote>
665
+ # <p>[!TIP] ...</p>
666
+ # </blockquote>
667
+ elif (
668
+ child.tag == "blockquote"
669
+ and len(child) > 0
670
+ and child[0].tag == "p"
671
+ and child[0].text is not None
672
+ and child[0].text.startswith("[!")
673
+ ):
674
+ return self._transform_alert(child)
675
+
676
+ # <details markdown="1">
677
+ # <summary>...</summary>
678
+ # ...
679
+ # </details>
680
+ elif child.tag == "details" and len(child) > 1 and child[0].tag == "summary":
681
+ return self._transform_section(child)
682
+
451
683
  # <img src="..." alt="..." />
452
684
  elif child.tag == "img":
453
685
  return self._transform_image(child)
@@ -516,12 +748,20 @@ class ConfluenceDocumentOptions:
516
748
 
517
749
  :param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
518
750
  plain text; when false, raise an exception.
751
+ :param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
752
+ conversion rules for the identifier.
753
+ :param generated_by: Text to use as the generated-by prompt.
519
754
  :param show_generated: Whether to display a prompt "This page has been generated with a tool."
755
+ :param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
756
+ :param diagram_output_format: Target image format for diagrams.
520
757
  """
521
758
 
522
759
  ignore_invalid_url: bool = False
760
+ heading_anchors: bool = False
523
761
  generated_by: Optional[str] = "This page has been generated with a tool."
524
762
  root_page_id: Optional[str] = None
763
+ render_mermaid: bool = False
764
+ diagram_output_format: Literal["png", "svg"] = "png"
525
765
 
526
766
 
527
767
  class ConfluenceDocument:
@@ -541,7 +781,7 @@ class ConfluenceDocument:
541
781
  self.options = options
542
782
  path = path.absolute()
543
783
 
544
- with open(path, "r") as f:
784
+ with open(path, "r", encoding="utf-8") as f:
545
785
  text = f.read()
546
786
 
547
787
  # extract Confluence page ID
@@ -579,7 +819,10 @@ class ConfluenceDocument:
579
819
 
580
820
  converter = ConfluenceStorageFormatConverter(
581
821
  ConfluenceConverterOptions(
582
- ignore_invalid_url=self.options.ignore_invalid_url
822
+ ignore_invalid_url=self.options.ignore_invalid_url,
823
+ heading_anchors=self.options.heading_anchors,
824
+ render_mermaid=self.options.render_mermaid,
825
+ diagram_output_format=self.options.diagram_output_format,
583
826
  ),
584
827
  path,
585
828
  page_metadata,
@@ -587,9 +830,10 @@ class ConfluenceDocument:
587
830
  converter.visit(self.root)
588
831
  self.links = converter.links
589
832
  self.images = converter.images
833
+ self.embedded_images = converter.embedded_images
590
834
 
591
835
  def xhtml(self) -> str:
592
- return _content_to_string(self.root)
836
+ return elements_to_string(self.root)
593
837
 
594
838
 
595
839
  def attachment_name(name: str) -> str:
@@ -612,10 +856,10 @@ def sanitize_confluence(html: str) -> str:
612
856
 
613
857
  root = elements_from_strings([html])
614
858
  ConfluenceStorageFormatCleaner().visit(root)
615
- return _content_to_string(root)
859
+ return elements_to_string(root)
616
860
 
617
861
 
618
- def _content_to_string(root: ET._Element) -> str:
862
+ def elements_to_string(root: ET._Element) -> str:
619
863
  xml = ET.tostring(root, encoding="utf8", method="xml").decode("utf8")
620
864
  m = re.match(r"^<root\s+[^>]*>(.*)</root>\s*$", xml, re.DOTALL)
621
865
  if m:
md2conf/mermaid.py ADDED
@@ -0,0 +1,54 @@
1
+ import os
2
+ import os.path
3
+ import shutil
4
+ import subprocess
5
+ from typing import Literal
6
+
7
+
8
+ def has_mmdc() -> bool:
9
+ "True if Mermaid diagram converter is available on the OS."
10
+
11
+ if os.name == "nt":
12
+ executable = "mmdc.cmd"
13
+ else:
14
+ executable = "mmdc"
15
+ return shutil.which(executable) is not None
16
+
17
+
18
+ def render(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
19
+ "Generates a PNG or SVG image from a Mermaid diagram source."
20
+
21
+ filename = f"tmp_mermaid.{output_format}"
22
+
23
+ if os.name == "nt":
24
+ executable = "mmdc.cmd"
25
+ else:
26
+ executable = "mmdc"
27
+ try:
28
+ cmd = [
29
+ executable,
30
+ "--input",
31
+ "-",
32
+ "--output",
33
+ filename,
34
+ "--outputFormat",
35
+ output_format,
36
+ ]
37
+ proc = subprocess.Popen(
38
+ cmd,
39
+ stdout=subprocess.PIPE,
40
+ stdin=subprocess.PIPE,
41
+ stderr=subprocess.PIPE,
42
+ text=False,
43
+ )
44
+ proc.communicate(input=source.encode("utf-8"))
45
+ if proc.returncode:
46
+ raise RuntimeError(
47
+ f"failed to convert Mermaid diagram; exit code: {proc.returncode}"
48
+ )
49
+ with open(filename, "rb") as image:
50
+ return image.read()
51
+
52
+ finally:
53
+ if os.path.exists(filename):
54
+ os.remove(filename)
md2conf/processor.py CHANGED
@@ -69,13 +69,13 @@ class Processor:
69
69
 
70
70
  document = ConfluenceDocument(path, self.options, page_metadata)
71
71
  content = document.xhtml()
72
- with open(path.with_suffix(".csf"), "w") as f:
72
+ with open(path.with_suffix(".csf"), "w", encoding="utf-8") as f:
73
73
  f.write(content)
74
74
 
75
75
  def _get_page(self, absolute_path: Path) -> ConfluencePageMetadata:
76
76
  "Extracts metadata from a Markdown file."
77
77
 
78
- with open(absolute_path, "r") as f:
78
+ with open(absolute_path, "r", encoding="utf-8") as f:
79
79
  document = f.read()
80
80
 
81
81
  qualified_id, document = extract_qualified_id(document)
@@ -1,16 +0,0 @@
1
- md2conf/__init__.py,sha256=SS79zE1Jss2UJQH39HX6zMvAPO2MGjq3jjHny4L6dvY,403
2
- md2conf/__main__.py,sha256=L0XaHfV3GOPnyceWxUGamZXPfQUL3Dfsf_VpWeF08Qo,4246
3
- md2conf/api.py,sha256=xYqQbdy-0d_mqv-zDT0DCcLs17HYLdY2LfWoY2jhMB8,14738
4
- md2conf/application.py,sha256=ksWeDQGQs6xvAS8wdghwBMgWsGI6KMQiwlH5qaKXGv4,5221
5
- md2conf/converter.py,sha256=xiaf5hQHXPE3psTTk_pHVip4NDfeU-hos3Kkl1Pju50,18236
6
- md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
7
- md2conf/processor.py,sha256=f0JG8jqjsGq2sbxMdHNz6EyZ1KQ92nCKKYU3fxP6C0o,3048
8
- md2conf/properties.py,sha256=oXvtPssbougM1BTE9ytcD_1Yjc3nd7DDSHqEr0QoZAU,1811
9
- md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- markdown_to_confluence-0.1.12.dist-info/LICENSE,sha256=Pv43so2bPfmKhmsrmXFyAvS7M30-1i1tzjz6-dfhyOo,1077
11
- markdown_to_confluence-0.1.12.dist-info/METADATA,sha256=HCmOX5YhQuc6v2szBgQa_VuGXaeZNKKBy8Q_Fi1GreQ,7875
12
- markdown_to_confluence-0.1.12.dist-info/WHEEL,sha256=cpQTJ5IWu9CdaPViMhC9YzF8gZuS5-vlfoFihTBC86A,91
13
- markdown_to_confluence-0.1.12.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
14
- markdown_to_confluence-0.1.12.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
15
- markdown_to_confluence-0.1.12.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
16
- markdown_to_confluence-0.1.12.dist-info/RECORD,,