markdown-to-confluence 0.5.3__py3-none-any.whl → 0.5.5__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.
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/METADATA +275 -208
- markdown_to_confluence-0.5.5.dist-info/RECORD +57 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/WHEEL +1 -1
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +61 -189
- md2conf/api.py +35 -69
- md2conf/attachment.py +4 -3
- md2conf/clio.py +226 -0
- md2conf/compatibility.py +5 -0
- md2conf/converter.py +239 -147
- md2conf/csf.py +89 -9
- md2conf/drawio/extension.py +3 -3
- md2conf/drawio/render.py +2 -0
- md2conf/extension.py +4 -0
- md2conf/external.py +25 -8
- md2conf/frontmatter.py +18 -6
- md2conf/image.py +17 -14
- md2conf/latex.py +8 -1
- md2conf/markdown.py +68 -1
- md2conf/mermaid/render.py +1 -1
- md2conf/options.py +95 -24
- md2conf/plantuml/extension.py +7 -7
- md2conf/plantuml/render.py +6 -7
- md2conf/png.py +10 -6
- md2conf/processor.py +24 -3
- md2conf/publisher.py +193 -36
- md2conf/reflection.py +74 -0
- md2conf/scanner.py +16 -6
- md2conf/serializer.py +12 -1
- md2conf/svg.py +131 -109
- md2conf/toc.py +72 -0
- md2conf/xml.py +45 -0
- markdown_to_confluence-0.5.3.dist-info/RECORD +0 -55
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/zip-safe +0 -0
- /md2conf/{puppeteer-config.json → mermaid/puppeteer-config.json} +0 -0
md2conf/csf.py
CHANGED
|
@@ -18,16 +18,16 @@ from lxml.builder import ElementMaker
|
|
|
18
18
|
ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
|
|
19
19
|
|
|
20
20
|
# XML namespaces typically associated with Confluence Storage Format documents
|
|
21
|
-
|
|
21
|
+
_NAMESPACES = {
|
|
22
22
|
"ac": "http://atlassian.com/content",
|
|
23
23
|
"ri": "http://atlassian.com/resource/identifier",
|
|
24
24
|
}
|
|
25
|
-
for key, value in
|
|
25
|
+
for key, value in _NAMESPACES.items():
|
|
26
26
|
ET.register_namespace(key, value)
|
|
27
27
|
|
|
28
28
|
HTML = ElementMaker()
|
|
29
|
-
AC_ELEM = ElementMaker(namespace=
|
|
30
|
-
RI_ELEM = ElementMaker(namespace=
|
|
29
|
+
AC_ELEM = ElementMaker(namespace=_NAMESPACES["ac"])
|
|
30
|
+
RI_ELEM = ElementMaker(namespace=_NAMESPACES["ri"])
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
class ParseError(RuntimeError):
|
|
@@ -39,11 +39,11 @@ def _qname(namespace_uri: str, name: str) -> str:
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def AC_ATTR(name: str) -> str:
|
|
42
|
-
return _qname(
|
|
42
|
+
return _qname(_NAMESPACES["ac"], name)
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def RI_ATTR(name: str) -> str:
|
|
46
|
-
return _qname(
|
|
46
|
+
return _qname(_NAMESPACES["ri"], name)
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
@contextmanager
|
|
@@ -77,7 +77,7 @@ def _elements_from_strings(dtd_path: Path, items: list[str]) -> ElementType:
|
|
|
77
77
|
load_dtd=True,
|
|
78
78
|
)
|
|
79
79
|
|
|
80
|
-
ns_attr_list = "".join(f' xmlns:{key}="{value}"' for key, value in
|
|
80
|
+
ns_attr_list = "".join(f' xmlns:{key}="{value}"' for key, value in _NAMESPACES.items())
|
|
81
81
|
|
|
82
82
|
data = [
|
|
83
83
|
'<?xml version="1.0"?>',
|
|
@@ -139,6 +139,9 @@ def content_to_string(content: str) -> str:
|
|
|
139
139
|
return _content_to_string(dtd_path, content)
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
_ROOT_REGEXP = re.compile(r"^<root\s+[^>]*>(.*)</root>\s*$", re.DOTALL)
|
|
143
|
+
|
|
144
|
+
|
|
142
145
|
def elements_to_string(root: ElementType) -> str:
|
|
143
146
|
"""
|
|
144
147
|
Converts a Confluence Storage Format element tree into an XML string to push to Confluence REST API.
|
|
@@ -148,8 +151,7 @@ def elements_to_string(root: ElementType) -> str:
|
|
|
148
151
|
"""
|
|
149
152
|
|
|
150
153
|
xml = ET.tostring(root, encoding="utf8", method="xml").decode("utf8")
|
|
151
|
-
m
|
|
152
|
-
if m:
|
|
154
|
+
if m := _ROOT_REGEXP.match(xml):
|
|
153
155
|
return m.group(1)
|
|
154
156
|
else:
|
|
155
157
|
raise ValueError("expected: Confluence content")
|
|
@@ -219,3 +221,81 @@ def normalize_inline(elem: ElementType) -> None:
|
|
|
219
221
|
# ignore empty elements
|
|
220
222
|
if item.tag != "p" or len(item) > 0 or item.text:
|
|
221
223
|
elem.append(item)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# elements in which whitespace is normalized
|
|
227
|
+
_NORMALIZED_ELEMENTS = [
|
|
228
|
+
"a",
|
|
229
|
+
"b",
|
|
230
|
+
"blockquote",
|
|
231
|
+
"code",
|
|
232
|
+
"del",
|
|
233
|
+
"details",
|
|
234
|
+
"div",
|
|
235
|
+
"em",
|
|
236
|
+
"h1",
|
|
237
|
+
"h2",
|
|
238
|
+
"h3",
|
|
239
|
+
"h4",
|
|
240
|
+
"h5",
|
|
241
|
+
"h6",
|
|
242
|
+
"i",
|
|
243
|
+
"li",
|
|
244
|
+
"p",
|
|
245
|
+
"span",
|
|
246
|
+
"strong",
|
|
247
|
+
"sub",
|
|
248
|
+
"summary",
|
|
249
|
+
"sup",
|
|
250
|
+
"td",
|
|
251
|
+
"th",
|
|
252
|
+
"u",
|
|
253
|
+
"{" + _NAMESPACES["ac"] + "}link-body",
|
|
254
|
+
"{" + _NAMESPACES["ac"] + "}rich-text-body",
|
|
255
|
+
"{" + _NAMESPACES["ac"] + "}task-body",
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
# elements that are recursed into for whitespace normalization
|
|
259
|
+
_PASSTHROUGH_ELEMENTS = _NORMALIZED_ELEMENTS + [
|
|
260
|
+
"ol",
|
|
261
|
+
"table",
|
|
262
|
+
"tbody",
|
|
263
|
+
"tfoot",
|
|
264
|
+
"thead",
|
|
265
|
+
"tr",
|
|
266
|
+
"ul",
|
|
267
|
+
"{" + _NAMESPACES["ac"] + "}link",
|
|
268
|
+
"{" + _NAMESPACES["ac"] + "}structured-macro",
|
|
269
|
+
"{" + _NAMESPACES["ac"] + "}task",
|
|
270
|
+
"{" + _NAMESPACES["ac"] + "}task-list",
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def normalize_whitespace(elem: ElementType) -> None:
|
|
275
|
+
"Replaces linefeed with space in contexts where whitespace normalization is permitted."
|
|
276
|
+
|
|
277
|
+
if not elem.text and len(elem) < 1:
|
|
278
|
+
# empty element
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
if elem.tag not in _PASSTHROUGH_ELEMENTS:
|
|
282
|
+
# element whose descendants are to be skipped
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
if elem.tag in _NORMALIZED_ELEMENTS:
|
|
286
|
+
if elem.text:
|
|
287
|
+
elem.text = elem.text.replace("\n", " ")
|
|
288
|
+
for child in elem:
|
|
289
|
+
if child.tail:
|
|
290
|
+
child.tail = child.tail.replace("\n", " ")
|
|
291
|
+
for child in elem:
|
|
292
|
+
normalize_whitespace(child)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def canonicalize(content: str) -> str:
|
|
296
|
+
"Converts a Confluence Storage Format (CSF) document to the normalized format."
|
|
297
|
+
|
|
298
|
+
root = elements_from_string(content)
|
|
299
|
+
for child in root:
|
|
300
|
+
normalize_whitespace(child)
|
|
301
|
+
return elements_to_string(root)
|
md2conf/drawio/extension.py
CHANGED
|
@@ -14,7 +14,7 @@ import lxml.etree as ET
|
|
|
14
14
|
from md2conf.attachment import EmbeddedFileData, ImageData, attachment_name
|
|
15
15
|
from md2conf.compatibility import override, path_relative_to
|
|
16
16
|
from md2conf.csf import AC_ATTR, AC_ELEM
|
|
17
|
-
from md2conf.extension import MarketplaceExtension
|
|
17
|
+
from md2conf.extension import ExtensionError, MarketplaceExtension
|
|
18
18
|
from md2conf.formatting import ImageAlignment, ImageAttributes
|
|
19
19
|
|
|
20
20
|
from .render import extract_diagram, render_diagram
|
|
@@ -38,11 +38,11 @@ class DrawioExtension(MarketplaceExtension):
|
|
|
38
38
|
elif absolute_path.name.endswith((".drawio", ".drawio.xml")):
|
|
39
39
|
return self._transform_drawio(absolute_path, attrs)
|
|
40
40
|
else:
|
|
41
|
-
raise
|
|
41
|
+
raise ExtensionError(f"unrecognized image format: {absolute_path.suffix}")
|
|
42
42
|
|
|
43
43
|
@override
|
|
44
44
|
def transform_fenced(self, content: str) -> ElementType:
|
|
45
|
-
raise
|
|
45
|
+
raise ExtensionError("draw.io diagrams cannot be defined in fenced code blocks")
|
|
46
46
|
|
|
47
47
|
def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
48
48
|
relative_path = path_relative_to(absolute_path, self.base_dir)
|
md2conf/drawio/render.py
CHANGED
|
@@ -47,8 +47,10 @@ def inflate(data: bytes) -> bytes:
|
|
|
47
47
|
:returns: Uncompressed data.
|
|
48
48
|
"""
|
|
49
49
|
|
|
50
|
+
# spellchecker: disable
|
|
50
51
|
# -zlib.MAX_WBITS indicates raw DEFLATE stream (no zlib/gzip headers)
|
|
51
52
|
return zlib.decompress(data, -zlib.MAX_WBITS)
|
|
53
|
+
# spellchecker: enable
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
def decompress_diagram(xml_data: bytes | str) -> ElementType:
|
md2conf/extension.py
CHANGED
md2conf/external.py
CHANGED
|
@@ -7,13 +7,14 @@ Copyright 2022-2026, Levente Hunyadi
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
|
+
import re
|
|
10
11
|
import subprocess
|
|
11
12
|
from typing import Sequence
|
|
12
13
|
|
|
13
14
|
LOGGER = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
def execute_subprocess(command: Sequence[str], data: bytes, *, application: str
|
|
17
|
+
def execute_subprocess(command: Sequence[str], data: bytes, *, application: str) -> bytes:
|
|
17
18
|
"""
|
|
18
19
|
Executes a subprocess, feeding input to stdin, and capturing output from stdout.
|
|
19
20
|
|
|
@@ -37,13 +38,29 @@ def execute_subprocess(command: Sequence[str], data: bytes, *, application: str
|
|
|
37
38
|
stdout, stderr = proc.communicate(input=data)
|
|
38
39
|
|
|
39
40
|
if proc.returncode:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
message = f"failed to execute {application}; exit code: {proc.returncode}"
|
|
42
|
+
LOGGER.error("Failed to execute %s; exit code: %d", application, proc.returncode)
|
|
43
|
+
messages = [message]
|
|
44
|
+
if stdout:
|
|
45
|
+
try:
|
|
46
|
+
console_output = stdout.decode("utf-8")
|
|
47
|
+
LOGGER.error(console_output)
|
|
48
|
+
messages.append(f"output:\n{console_output}")
|
|
49
|
+
except UnicodeDecodeError:
|
|
50
|
+
LOGGER.error("%s returned binary data on stdout", application)
|
|
51
|
+
pass
|
|
52
|
+
if stderr:
|
|
53
|
+
try:
|
|
54
|
+
console_error = stderr.decode("utf-8")
|
|
55
|
+
LOGGER.error(console_error)
|
|
56
|
+
|
|
57
|
+
# omit Node.js exception stack trace
|
|
58
|
+
console_error = re.sub(r"^\s+at.*:\d+:\d+\)$\n", "", console_error, flags=re.MULTILINE).rstrip()
|
|
59
|
+
|
|
60
|
+
messages.append(f"error:\n{console_error}")
|
|
61
|
+
except UnicodeDecodeError:
|
|
62
|
+
LOGGER.error("%s returned binary data on stderr", application)
|
|
63
|
+
pass
|
|
47
64
|
raise RuntimeError("\n".join(messages))
|
|
48
65
|
|
|
49
66
|
return stdout
|
md2conf/frontmatter.py
CHANGED
|
@@ -8,7 +8,8 @@ Copyright 2022-2026, Levente Hunyadi
|
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
10
|
import typing
|
|
11
|
-
from
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TypeVar
|
|
12
13
|
|
|
13
14
|
import yaml
|
|
14
15
|
|
|
@@ -43,19 +44,30 @@ def extract_value(pattern: str, text: str) -> tuple[str | None, str]:
|
|
|
43
44
|
def extract_frontmatter_block(text: str) -> tuple[str | None, str]:
|
|
44
45
|
"Extracts the front-matter from a Markdown document as a blob of unparsed text."
|
|
45
46
|
|
|
46
|
-
return extract_value(r"(?ms)\A
|
|
47
|
+
return extract_value(r"(?ms)\A---\n(.+?)^---\n", text)
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
@dataclass
|
|
51
|
+
class FrontMatterProperties:
|
|
52
|
+
data: dict[str, JsonType] | None
|
|
53
|
+
inner_line_count: int
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def outer_line_count(self) -> int:
|
|
57
|
+
return self.inner_line_count + 2 # account for enclosing `--` (double dash)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_frontmatter_json(text: str) -> tuple[FrontMatterProperties | None, str]:
|
|
50
61
|
"Extracts the front-matter from a Markdown document as a dictionary."
|
|
51
62
|
|
|
52
63
|
block, text = extract_frontmatter_block(text)
|
|
53
64
|
|
|
54
|
-
properties:
|
|
65
|
+
properties: FrontMatterProperties | None = None
|
|
55
66
|
if block is not None:
|
|
67
|
+
inner_line_count = block.count("\n")
|
|
56
68
|
data = yaml.safe_load(block)
|
|
57
69
|
if isinstance(data, dict):
|
|
58
|
-
properties = typing.cast(dict[str, JsonType], data)
|
|
70
|
+
properties = FrontMatterProperties(typing.cast(dict[str, JsonType], data), inner_line_count)
|
|
59
71
|
|
|
60
72
|
return properties, text
|
|
61
73
|
|
|
@@ -65,6 +77,6 @@ def extract_frontmatter_object(tp: type[D], text: str) -> tuple[D | None, str]:
|
|
|
65
77
|
|
|
66
78
|
value_object: D | None = None
|
|
67
79
|
if properties is not None:
|
|
68
|
-
value_object = json_to_object(tp, properties)
|
|
80
|
+
value_object = json_to_object(tp, properties.data)
|
|
69
81
|
|
|
70
82
|
return value_object, text
|
md2conf/image.py
CHANGED
|
@@ -58,12 +58,13 @@ class ImageGenerator:
|
|
|
58
58
|
|
|
59
59
|
# infer SVG dimensions if not already specified
|
|
60
60
|
if absolute_path.suffix == ".svg" and attrs.width is None and attrs.height is None:
|
|
61
|
-
|
|
62
|
-
if
|
|
61
|
+
dimensions = get_svg_dimensions(absolute_path)
|
|
62
|
+
if dimensions is not None:
|
|
63
|
+
width, height = dimensions
|
|
63
64
|
attrs = ImageAttributes(
|
|
64
65
|
context=attrs.context,
|
|
65
|
-
width=
|
|
66
|
-
height=
|
|
66
|
+
width=width,
|
|
67
|
+
height=height,
|
|
67
68
|
alt=attrs.alt,
|
|
68
69
|
title=attrs.title,
|
|
69
70
|
caption=attrs.caption,
|
|
@@ -74,21 +75,23 @@ class ImageGenerator:
|
|
|
74
75
|
image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
|
|
75
76
|
return self.create_attached_image(image_name, attrs)
|
|
76
77
|
|
|
77
|
-
def transform_attached_data(
|
|
78
|
+
def transform_attached_data(
|
|
79
|
+
self, image_data: bytes, attrs: ImageAttributes, relative_path: Path | None = None, *, image_type: str = "embedded"
|
|
80
|
+
) -> ElementType:
|
|
78
81
|
"Emits Confluence Storage Format XHTML for an attached raster or vector image."
|
|
79
82
|
|
|
80
83
|
# extract dimensions and update attributes based on format
|
|
81
|
-
|
|
82
|
-
height: int | None
|
|
84
|
+
dimensions: tuple[int, int] | None
|
|
83
85
|
match self.options.output_format:
|
|
84
86
|
case "svg":
|
|
85
|
-
image_data,
|
|
87
|
+
image_data, dimensions = fix_svg_get_dimensions(image_data)
|
|
86
88
|
case "png":
|
|
87
|
-
|
|
89
|
+
dimensions = extract_png_dimensions(data=image_data)
|
|
88
90
|
|
|
89
91
|
# only update attributes if we successfully extracted dimensions and the base attributes don't already have explicit dimensions
|
|
90
|
-
if
|
|
92
|
+
if dimensions is not None and (attrs.width is None and attrs.height is None):
|
|
91
93
|
# create updated image attributes with extracted dimensions
|
|
94
|
+
width, height = dimensions
|
|
92
95
|
attrs = ImageAttributes(
|
|
93
96
|
context=attrs.context,
|
|
94
97
|
width=width,
|
|
@@ -99,15 +102,15 @@ class ImageGenerator:
|
|
|
99
102
|
alignment=attrs.alignment,
|
|
100
103
|
)
|
|
101
104
|
|
|
102
|
-
# generate filename
|
|
105
|
+
# generate filename
|
|
103
106
|
if relative_path is not None:
|
|
104
107
|
image_filename = attachment_name(relative_path.with_suffix(f".{self.options.output_format}"))
|
|
105
|
-
self.attachments.add_embed(image_filename, EmbeddedFileData(image_data, attrs.alt))
|
|
106
108
|
else:
|
|
107
109
|
image_hash = hashlib.md5(image_data).hexdigest()
|
|
108
|
-
image_filename = attachment_name(f"
|
|
109
|
-
self.attachments.add_embed(image_filename, EmbeddedFileData(image_data))
|
|
110
|
+
image_filename = attachment_name(f"{image_type}_{image_hash}.{self.options.output_format}")
|
|
110
111
|
|
|
112
|
+
# add as attachment
|
|
113
|
+
self.attachments.add_embed(image_filename, EmbeddedFileData(image_data, attrs.alt))
|
|
111
114
|
return self.create_attached_image(image_filename, attrs)
|
|
112
115
|
|
|
113
116
|
def create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ElementType:
|
md2conf/latex.py
CHANGED
|
@@ -52,6 +52,13 @@ else:
|
|
|
52
52
|
# spellchecker:disable-next-line
|
|
53
53
|
fig.text(x=0, y=0, s=f"${expression}$", fontsize=font_size) # pyright: ignore[reportUnknownMemberType]
|
|
54
54
|
|
|
55
|
+
metadata: dict[str, str | None] = {"Title": expression}
|
|
56
|
+
match format:
|
|
57
|
+
case "png":
|
|
58
|
+
metadata.update({"Software": None})
|
|
59
|
+
case "svg":
|
|
60
|
+
metadata.update({"Creator": None, "Date": None, "Format": None, "Type": None})
|
|
61
|
+
|
|
55
62
|
# save the image
|
|
56
63
|
fig.savefig( # pyright: ignore[reportUnknownMemberType]
|
|
57
64
|
f,
|
|
@@ -59,7 +66,7 @@ else:
|
|
|
59
66
|
format=format,
|
|
60
67
|
bbox_inches="tight",
|
|
61
68
|
pad_inches=0.0,
|
|
62
|
-
metadata=
|
|
69
|
+
metadata=metadata,
|
|
63
70
|
)
|
|
64
71
|
|
|
65
72
|
# close the figure to free memory
|
md2conf/markdown.py
CHANGED
|
@@ -6,6 +6,7 @@ Copyright 2022-2026, Levente Hunyadi
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import re
|
|
9
10
|
import xml.etree.ElementTree
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
@@ -86,7 +87,7 @@ _CONVERTER = markdown.Markdown(
|
|
|
86
87
|
"sane_lists",
|
|
87
88
|
],
|
|
88
89
|
extension_configs={
|
|
89
|
-
"footnotes": {"BACKLINK_TITLE": ""},
|
|
90
|
+
"footnotes": {"BACKLINK_TITLE": ""}, # spellchecker:disable-line
|
|
90
91
|
"pymdownx.arithmatex": {"generic": True, "preview": False, "tex_inline_wrap": ["", ""], "tex_block_wrap": ["", ""]},
|
|
91
92
|
"pymdownx.emoji": {"emoji_generator": _emoji_generator},
|
|
92
93
|
"pymdownx.highlight": {
|
|
@@ -114,3 +115,69 @@ def markdown_to_html(content: str) -> str:
|
|
|
114
115
|
_CONVERTER.reset()
|
|
115
116
|
html = _CONVERTER.convert(content)
|
|
116
117
|
return html
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# matches the start and end marker of a fenced code block
|
|
121
|
+
_FENCED_CODE_REGEXP = re.compile(r"^\s*(?:`{3,}|~{3,})", re.MULTILINE)
|
|
122
|
+
|
|
123
|
+
# matches a regular table row (but not the column alignment row)
|
|
124
|
+
_TABLE_ROW_REGEXP = re.compile(r"^\|\s*([^\s:-]+.*?)\s*\|$", re.MULTILINE)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def markdown_with_line_numbers(input_lines: list[str], start_line_number: int) -> list[str]:
|
|
128
|
+
"""
|
|
129
|
+
Injects XHTML line number markers in Markdown text.
|
|
130
|
+
|
|
131
|
+
Unfortunately, Python-Markdown doesn't propagate line numbers to downstream processors, making it challenging to
|
|
132
|
+
display helpful error messages to end users. This function injects XHTML self-closing tags into the Markdown source:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
<line-number value="#" />
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
When tree visitors process the XHTML content generated by Python-Markdown and an error is triggered, the exception
|
|
139
|
+
handler can use these placeholder elements to guide end users in which part of the Markdown file they should look
|
|
140
|
+
by translating a tree node in the intermediate output into a line number in the source.
|
|
141
|
+
|
|
142
|
+
:param input_lines: Markdown source file split into lines.
|
|
143
|
+
:param start_line_number: The first line of the Markdown document excluding front-matter, or 1 if there is no front-matter.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
output_lines: list[str] = []
|
|
147
|
+
|
|
148
|
+
fence_marker: str | None = None
|
|
149
|
+
for number, line in enumerate(input_lines, start=start_line_number):
|
|
150
|
+
if not line:
|
|
151
|
+
output_lines.append("")
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# fenced code blocks
|
|
155
|
+
if fence_match := _FENCED_CODE_REGEXP.match(line):
|
|
156
|
+
marker = fence_match.group()
|
|
157
|
+
if fence_marker is None:
|
|
158
|
+
fence_marker = marker
|
|
159
|
+
elif marker == fence_marker:
|
|
160
|
+
fence_marker = None
|
|
161
|
+
elif fence_marker is None:
|
|
162
|
+
# not inside a fenced code block
|
|
163
|
+
if (
|
|
164
|
+
# not an admonition
|
|
165
|
+
not line.startswith("!!! ")
|
|
166
|
+
# not a Setext heading
|
|
167
|
+
and not (line.startswith("===") or line.startswith("---"))
|
|
168
|
+
# not a decorated ATX heading
|
|
169
|
+
and not line.endswith("#")
|
|
170
|
+
# not a math block formula
|
|
171
|
+
and not (line.startswith("$$") and line.endswith("$$"))
|
|
172
|
+
# not a Markdown table
|
|
173
|
+
and not (line.startswith("|") or line.endswith("|"))
|
|
174
|
+
# not a block-level HTML tag
|
|
175
|
+
and not (line.startswith("<") or line.endswith(">"))
|
|
176
|
+
):
|
|
177
|
+
line = f'{line}<line-number value="{number}" />'
|
|
178
|
+
elif row_match := _TABLE_ROW_REGEXP.match(line):
|
|
179
|
+
line = f'| {row_match.group(1)}<line-number value="{number}" /> |'
|
|
180
|
+
|
|
181
|
+
output_lines.append(line)
|
|
182
|
+
|
|
183
|
+
return output_lines
|
md2conf/mermaid/render.py
CHANGED
|
@@ -67,7 +67,7 @@ def render_diagram(source: str, output_format: Literal["png", "svg"] = "png", co
|
|
|
67
67
|
str(config.scale or 2),
|
|
68
68
|
]
|
|
69
69
|
if _is_docker():
|
|
70
|
-
root = os.path.dirname(
|
|
70
|
+
root = os.path.dirname(__file__)
|
|
71
71
|
cmd.extend(["-p", os.path.join(root, "puppeteer-config.json")])
|
|
72
72
|
|
|
73
73
|
return execute_subprocess(cmd, source.encode("utf-8"), application="Mermaid")
|
md2conf/options.py
CHANGED
|
@@ -6,10 +6,11 @@ Copyright 2022-2026, Levente Hunyadi
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import
|
|
10
|
-
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import dataclass, field
|
|
11
10
|
from typing import Literal
|
|
12
11
|
|
|
12
|
+
from .clio import boolean_option, composite_option, value_option
|
|
13
|
+
|
|
13
14
|
|
|
14
15
|
@dataclass
|
|
15
16
|
class ConfluencePageID:
|
|
@@ -25,8 +26,11 @@ class ImageLayoutOptions:
|
|
|
25
26
|
:param max_width: Maximum display width for images [px]. Wider images are scaled down for page display. Original size kept for full-size viewing.
|
|
26
27
|
"""
|
|
27
28
|
|
|
28
|
-
alignment: Literal["center", "left", "right"]
|
|
29
|
-
max_width: int | None =
|
|
29
|
+
alignment: Literal["center", "left", "right", None] = field(default=None, metadata=value_option("Alignment for block-level images and formulas."))
|
|
30
|
+
max_width: int | None = field(
|
|
31
|
+
default=None,
|
|
32
|
+
metadata=value_option("Maximum display width for images [px]. Wider images are scaled down for page display."),
|
|
33
|
+
)
|
|
30
34
|
|
|
31
35
|
|
|
32
36
|
@dataclass
|
|
@@ -38,8 +42,8 @@ class TableLayoutOptions:
|
|
|
38
42
|
:param display_mode: Whether to use fixed or responsive column widths.
|
|
39
43
|
"""
|
|
40
44
|
|
|
41
|
-
width: int | None = None
|
|
42
|
-
display_mode: Literal["
|
|
45
|
+
width: int | None = field(default=None, metadata=value_option("Maximum table width in pixels."))
|
|
46
|
+
display_mode: Literal["responsive", "fixed"] = field(default="responsive", metadata=value_option("Set table display mode."))
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
@dataclass
|
|
@@ -54,9 +58,9 @@ class LayoutOptions:
|
|
|
54
58
|
:param alignment: Default alignment (unless overridden with more specific setting).
|
|
55
59
|
"""
|
|
56
60
|
|
|
57
|
-
image: ImageLayoutOptions =
|
|
58
|
-
table: TableLayoutOptions =
|
|
59
|
-
alignment: Literal["center", "left", "right"]
|
|
61
|
+
image: ImageLayoutOptions = field(default_factory=ImageLayoutOptions, metadata=composite_option())
|
|
62
|
+
table: TableLayoutOptions = field(default_factory=TableLayoutOptions, metadata=composite_option())
|
|
63
|
+
alignment: Literal["center", "left", "right", None] = field(default=None, metadata=value_option("Default alignment for block-level content."))
|
|
60
64
|
|
|
61
65
|
def get_image_alignment(self) -> Literal["center", "left", "right"]:
|
|
62
66
|
return self.image.alignment or self.alignment or "center"
|
|
@@ -69,8 +73,8 @@ class ConverterOptions:
|
|
|
69
73
|
|
|
70
74
|
:param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
|
|
71
75
|
conversion rules for the identifier.
|
|
72
|
-
:param
|
|
73
|
-
|
|
76
|
+
:param force_valid_url: If enabled, raise an exception when relative URLs point to an invalid location. If disabled,
|
|
77
|
+
ignore invalid URLs, emit a warning and replace the anchor with plain text.
|
|
74
78
|
:param skip_title_heading: Whether to remove the first heading from document body when used as page title.
|
|
75
79
|
:param prefer_raster: Whether to choose PNG files over SVG files when available.
|
|
76
80
|
:param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
|
|
@@ -83,18 +87,81 @@ class ConverterOptions:
|
|
|
83
87
|
:param layout: Layout options for content on a Confluence page.
|
|
84
88
|
"""
|
|
85
89
|
|
|
86
|
-
heading_anchors: bool =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
heading_anchors: bool = field(
|
|
91
|
+
default=False,
|
|
92
|
+
metadata=boolean_option(
|
|
93
|
+
"Place an anchor at each section heading with GitHub-style same-page identifiers.",
|
|
94
|
+
"Omit the extra anchor from section headings. (May break manually placed same-page references.)",
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
force_valid_url: bool = field(
|
|
98
|
+
default=True,
|
|
99
|
+
metadata=boolean_option(
|
|
100
|
+
"Raise an error when relative URLs point to an invalid location.",
|
|
101
|
+
"Emit a warning but otherwise ignore relative URLs that point to an invalid location.",
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
skip_title_heading: bool = field(
|
|
105
|
+
default=False,
|
|
106
|
+
metadata=boolean_option(
|
|
107
|
+
"Remove the first heading from document body when it is used as the page title (does not apply if title comes from front-matter).",
|
|
108
|
+
"Keep the first heading in document body even when used as page title.",
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
prefer_raster: bool = field(
|
|
112
|
+
default=True,
|
|
113
|
+
metadata=boolean_option(
|
|
114
|
+
"Prefer PNG over SVG when both exist.",
|
|
115
|
+
"Use SVG files directly instead of preferring PNG equivalents.",
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
render_drawio: bool = field(
|
|
119
|
+
default=True,
|
|
120
|
+
metadata=boolean_option(
|
|
121
|
+
"Render draw.io diagrams as image files. (Installed utility required to covert.)",
|
|
122
|
+
"Upload draw.io diagram sources as Confluence page attachments. (Marketplace app required to display.)",
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
render_mermaid: bool = field(
|
|
126
|
+
default=True,
|
|
127
|
+
metadata=boolean_option(
|
|
128
|
+
"Render Mermaid diagrams as image files. (Installed utility required to convert.)",
|
|
129
|
+
"Upload Mermaid diagram sources as Confluence page attachments. (Marketplace app required to display.)",
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
render_plantuml: bool = field(
|
|
133
|
+
default=True,
|
|
134
|
+
metadata=boolean_option(
|
|
135
|
+
"Render PlantUML diagrams as image files. (Installed utility required to convert.)",
|
|
136
|
+
"Upload PlantUML diagram sources as Confluence page attachments. (Marketplace app required to display.)",
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
render_latex: bool = field(
|
|
140
|
+
default=True,
|
|
141
|
+
metadata=boolean_option(
|
|
142
|
+
"Render LaTeX formulas as image files. (Matplotlib required to convert.)",
|
|
143
|
+
"Inline LaTeX formulas in Confluence page. (Marketplace app required to display.)",
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
diagram_output_format: Literal["png", "svg"] = field(
|
|
147
|
+
default="png",
|
|
148
|
+
metadata=value_option("Format for rendering Mermaid and draw.io diagrams."),
|
|
149
|
+
)
|
|
150
|
+
webui_links: bool = field(
|
|
151
|
+
default=False,
|
|
152
|
+
metadata=boolean_option(
|
|
153
|
+
"Enable Confluence Web UI links. (Typically required for on-prem versions of Confluence.)",
|
|
154
|
+
"Use hierarchical links including space and page ID.",
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
use_panel: bool = field(
|
|
158
|
+
default=False,
|
|
159
|
+
metadata=boolean_option(
|
|
160
|
+
"Transform admonitions and alerts into a Confluence custom panel.",
|
|
161
|
+
"Use standard Confluence macro types for admonitions and alerts (info, tip, note and warning).",
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
layout: LayoutOptions = field(default_factory=LayoutOptions, metadata=composite_option())
|
|
98
165
|
|
|
99
166
|
|
|
100
167
|
@dataclass
|
|
@@ -106,11 +173,15 @@ class DocumentOptions:
|
|
|
106
173
|
:param keep_hierarchy: Whether to maintain source directory structure when exporting to Confluence.
|
|
107
174
|
:param title_prefix: String to prepend to Confluence page title for each published page.
|
|
108
175
|
:param generated_by: Text to use as the generated-by prompt (or `None` to omit a prompt).
|
|
176
|
+
:param skip_update: Whether to skip saving Confluence page ID in Markdown files.
|
|
109
177
|
:param converter: Options for converting an HTML tree into Confluence Storage Format.
|
|
178
|
+
:param line_numbers: Inject line numbers in Markdown source to help localize conversion errors.
|
|
110
179
|
"""
|
|
111
180
|
|
|
112
181
|
root_page_id: ConfluencePageID | None = None
|
|
113
182
|
keep_hierarchy: bool = False
|
|
114
183
|
title_prefix: str | None = None
|
|
115
184
|
generated_by: str | None = "This page has been generated with a tool."
|
|
116
|
-
|
|
185
|
+
skip_update: bool = False
|
|
186
|
+
converter: ConverterOptions = field(default_factory=ConverterOptions)
|
|
187
|
+
line_numbers: bool = False
|