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.
Files changed (38) hide show
  1. {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/METADATA +275 -208
  2. markdown_to_confluence-0.5.5.dist-info/RECORD +57 -0
  3. {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/WHEEL +1 -1
  4. md2conf/__init__.py +1 -1
  5. md2conf/__main__.py +61 -189
  6. md2conf/api.py +35 -69
  7. md2conf/attachment.py +4 -3
  8. md2conf/clio.py +226 -0
  9. md2conf/compatibility.py +5 -0
  10. md2conf/converter.py +239 -147
  11. md2conf/csf.py +89 -9
  12. md2conf/drawio/extension.py +3 -3
  13. md2conf/drawio/render.py +2 -0
  14. md2conf/extension.py +4 -0
  15. md2conf/external.py +25 -8
  16. md2conf/frontmatter.py +18 -6
  17. md2conf/image.py +17 -14
  18. md2conf/latex.py +8 -1
  19. md2conf/markdown.py +68 -1
  20. md2conf/mermaid/render.py +1 -1
  21. md2conf/options.py +95 -24
  22. md2conf/plantuml/extension.py +7 -7
  23. md2conf/plantuml/render.py +6 -7
  24. md2conf/png.py +10 -6
  25. md2conf/processor.py +24 -3
  26. md2conf/publisher.py +193 -36
  27. md2conf/reflection.py +74 -0
  28. md2conf/scanner.py +16 -6
  29. md2conf/serializer.py +12 -1
  30. md2conf/svg.py +131 -109
  31. md2conf/toc.py +72 -0
  32. md2conf/xml.py +45 -0
  33. markdown_to_confluence-0.5.3.dist-info/RECORD +0 -55
  34. {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/entry_points.txt +0 -0
  35. {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/licenses/LICENSE +0 -0
  36. {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/top_level.txt +0 -0
  37. {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.5.dist-info}/zip-safe +0 -0
  38. /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
- _namespaces = {
21
+ _NAMESPACES = {
22
22
  "ac": "http://atlassian.com/content",
23
23
  "ri": "http://atlassian.com/resource/identifier",
24
24
  }
25
- for key, value in _namespaces.items():
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=_namespaces["ac"])
30
- RI_ELEM = ElementMaker(namespace=_namespaces["ri"])
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(_namespaces["ac"], name)
42
+ return _qname(_NAMESPACES["ac"], name)
43
43
 
44
44
 
45
45
  def RI_ATTR(name: str) -> str:
46
- return _qname(_namespaces["ri"], name)
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 _namespaces.items())
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 = re.match(r"^<root\s+[^>]*>(.*)</root>\s*$", xml, re.DOTALL)
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)
@@ -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 RuntimeError(f"unrecognized image format: {absolute_path.suffix}")
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 RuntimeError("draw.io diagrams cannot be defined in fenced code blocks")
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
@@ -19,6 +19,10 @@ from .image import ImageGenerator
19
19
  ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
20
20
 
21
21
 
22
+ class ExtensionError(RuntimeError):
23
+ pass
24
+
25
+
22
26
  @dataclass
23
27
  class ExtensionOptions:
24
28
  """
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 | None = None) -> bytes:
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
- messages = [f"failed to execute {application or 'application'}; exit code: {proc.returncode}"]
41
- console_output = stdout.decode("utf-8")
42
- if console_output:
43
- messages.append(f"output:\n{console_output}")
44
- console_error = stderr.decode("utf-8")
45
- if console_error:
46
- messages.append(f"error:\n{console_error}")
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 typing import Any, TypeVar
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---$(.+?)^---$", text)
47
+ return extract_value(r"(?ms)\A---\n(.+?)^---\n", text)
47
48
 
48
49
 
49
- def extract_frontmatter_json(text: str) -> tuple[dict[str, JsonType] | None, str]:
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: dict[str, Any] | None = None
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
- svg_width, svg_height = get_svg_dimensions(absolute_path)
62
- if svg_width is not None:
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=svg_width,
66
- height=svg_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(self, image_data: bytes, attrs: ImageAttributes, relative_path: Path | None = None) -> ElementType:
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
- width: int | None
82
- height: int | None
84
+ dimensions: tuple[int, int] | None
83
85
  match self.options.output_format:
84
86
  case "svg":
85
- image_data, width, height = fix_svg_get_dimensions(image_data)
87
+ image_data, dimensions = fix_svg_get_dimensions(image_data)
86
88
  case "png":
87
- width, height = extract_png_dimensions(data=image_data)
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 (width is not None or height is not None) and (attrs.width is None and attrs.height is None):
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 and add as attachment
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"embedded_{image_hash}.{self.options.output_format}")
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={"Title": expression} if format == "png" else None,
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(os.path.dirname(__file__))
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 dataclasses
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"] | None = None
29
- max_width: int | None = 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["fixed", "responsive"] | None = None
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 = dataclasses.field(default_factory=ImageLayoutOptions)
58
- table: TableLayoutOptions = dataclasses.field(default_factory=TableLayoutOptions)
59
- alignment: Literal["center", "left", "right"] | None = None
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 ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
73
- plain text; when false, raise an exception.
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 = False
87
- ignore_invalid_url: bool = False
88
- skip_title_heading: bool = False
89
- prefer_raster: bool = True
90
- render_drawio: bool = False
91
- render_mermaid: bool = False
92
- render_plantuml: bool = False
93
- render_latex: bool = False
94
- diagram_output_format: Literal["png", "svg"] = "png"
95
- webui_links: bool = False
96
- use_panel: bool = False
97
- layout: LayoutOptions = dataclasses.field(default_factory=LayoutOptions)
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
- converter: ConverterOptions = dataclasses.field(default_factory=ConverterOptions)
185
+ skip_update: bool = False
186
+ converter: ConverterOptions = field(default_factory=ConverterOptions)
187
+ line_numbers: bool = False