markdown-to-confluence 0.4.6__py3-none-any.whl → 0.4.7__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.
md2conf/converter.py CHANGED
@@ -39,6 +39,8 @@ from .toc import TableOfContentsBuilder
39
39
  from .uri import is_absolute_url, to_uuid_urn
40
40
  from .xml import element_to_text
41
41
 
42
+ ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
43
+
42
44
 
43
45
  def get_volatile_attributes() -> list[str]:
44
46
  "Returns a list of volatile attributes that frequently change as a Confluence storage format XHTML document is updated."
@@ -180,7 +182,7 @@ _LANGUAGES = {
180
182
 
181
183
 
182
184
  class NodeVisitor(ABC):
183
- def visit(self, node: ET._Element) -> None:
185
+ def visit(self, node: ElementType) -> None:
184
186
  "Recursively visits all descendants of this node."
185
187
 
186
188
  if len(node) < 1:
@@ -200,7 +202,7 @@ class NodeVisitor(ABC):
200
202
  self.visit(source)
201
203
 
202
204
  @abstractmethod
203
- def transform(self, child: ET._Element) -> Optional[ET._Element]: ...
205
+ def transform(self, child: ElementType) -> Optional[ElementType]: ...
204
206
 
205
207
 
206
208
  def title_to_identifier(title: str) -> str:
@@ -212,7 +214,7 @@ def title_to_identifier(title: str) -> str:
212
214
  return s
213
215
 
214
216
 
215
- def element_text_starts_with_any(node: ET._Element, prefixes: list[str]) -> bool:
217
+ def element_text_starts_with_any(node: ElementType, prefixes: list[str]) -> bool:
216
218
  "True if the text contained in an element starts with any of the specified prefix strings."
217
219
 
218
220
  if node.text is None:
@@ -220,7 +222,7 @@ def element_text_starts_with_any(node: ET._Element, prefixes: list[str]) -> bool
220
222
  return starts_with_any(node.text, prefixes)
221
223
 
222
224
 
223
- def is_placeholder_for(node: ET._Element, name: str) -> bool:
225
+ def is_placeholder_for(node: ElementType, name: str) -> bool:
224
226
  """
225
227
  Identifies a Confluence widget placeholder, e.g. `[[_TOC_]]` or `[[_LISTING_]]`.
226
228
 
@@ -247,6 +249,15 @@ class FormattingContext(enum.Enum):
247
249
  INLINE = "inline"
248
250
 
249
251
 
252
+ @enum.unique
253
+ class ImageAlignment(enum.Enum):
254
+ "Determines whether to align block-level images to center, left or right."
255
+
256
+ CENTER = "center"
257
+ LEFT = "left"
258
+ RIGHT = "right"
259
+
260
+
250
261
  @dataclass
251
262
  class ImageAttributes:
252
263
  """
@@ -258,6 +269,7 @@ class ImageAttributes:
258
269
  :param alt: Alternate text.
259
270
  :param title: Title text (a.k.a. image tooltip).
260
271
  :param caption: Caption text (shown below figure).
272
+ :param alignment: Alignment for block-level images.
261
273
  """
262
274
 
263
275
  context: FormattingContext
@@ -266,6 +278,7 @@ class ImageAttributes:
266
278
  alt: Optional[str]
267
279
  title: Optional[str]
268
280
  caption: Optional[str]
281
+ alignment: ImageAlignment = ImageAlignment.CENTER
269
282
 
270
283
  def __post_init__(self) -> None:
271
284
  if self.caption is None and self.context is FormattingContext.BLOCK:
@@ -274,8 +287,16 @@ class ImageAttributes:
274
287
  def as_dict(self) -> dict[str, str]:
275
288
  attributes: dict[str, str] = {}
276
289
  if self.context is FormattingContext.BLOCK:
277
- attributes[AC_ATTR("align")] = "center"
278
- attributes[AC_ATTR("layout")] = "center"
290
+ if self.alignment is ImageAlignment.LEFT:
291
+ attributes[AC_ATTR("align")] = "left"
292
+ attributes[AC_ATTR("layout")] = "align-start"
293
+ elif self.alignment is ImageAlignment.RIGHT:
294
+ attributes[AC_ATTR("align")] = "right"
295
+ attributes[AC_ATTR("layout")] = "align-end"
296
+ else:
297
+ attributes[AC_ATTR("align")] = "center"
298
+ attributes[AC_ATTR("layout")] = "center"
299
+
279
300
  if self.width is not None:
280
301
  attributes[AC_ATTR("original-width")] = str(self.width)
281
302
  if self.height is not None:
@@ -311,8 +332,12 @@ class ImageAttributes:
311
332
  raise NotImplementedError("match not exhaustive for enumeration")
312
333
 
313
334
 
314
- ImageAttributes.EMPTY_BLOCK = ImageAttributes(FormattingContext.BLOCK, None, None, None, None, None)
315
- ImageAttributes.EMPTY_INLINE = ImageAttributes(FormattingContext.INLINE, None, None, None, None, None)
335
+ ImageAttributes.EMPTY_BLOCK = ImageAttributes(
336
+ FormattingContext.BLOCK, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
337
+ )
338
+ ImageAttributes.EMPTY_INLINE = ImageAttributes(
339
+ FormattingContext.INLINE, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
340
+ )
316
341
 
317
342
 
318
343
  @dataclass
@@ -330,6 +355,8 @@ class ConfluenceConverterOptions:
330
355
  :param render_latex: Whether to pre-render LaTeX formulas into PNG/SVG images.
331
356
  :param diagram_output_format: Target image format for diagrams.
332
357
  :param webui_links: When true, convert relative URLs to Confluence Web UI links.
358
+ :param alignment: Alignment for block-level images and formulas.
359
+ :param use_panel: Whether to transform admonitions and alerts into a Confluence custom panel.
333
360
  """
334
361
 
335
362
  ignore_invalid_url: bool = False
@@ -340,6 +367,8 @@ class ConfluenceConverterOptions:
340
367
  render_latex: bool = False
341
368
  diagram_output_format: Literal["png", "svg"] = "png"
342
369
  webui_links: bool = False
370
+ alignment: Literal["center", "left", "right"] = "center"
371
+ use_panel: bool = False
343
372
 
344
373
 
345
374
  @dataclass
@@ -354,6 +383,43 @@ class EmbeddedFileData:
354
383
  description: Optional[str] = None
355
384
 
356
385
 
386
+ @dataclass
387
+ class ConfluencePanel:
388
+ emoji: str
389
+ emoji_shortname: str
390
+ background_color: str
391
+ from_class: ClassVar[dict[str, "ConfluencePanel"]]
392
+
393
+ def __init__(self, emoji: str, emoji_shortname: str, background_color: str) -> None:
394
+ self.emoji = emoji
395
+ self.emoji_shortname = emoji_shortname
396
+ self.background_color = background_color
397
+
398
+ @property
399
+ def emoji_unicode(self) -> str:
400
+ return "-".join(f"{ord(ch):x}" for ch in self.emoji)
401
+
402
+ @property
403
+ def emoji_html(self) -> str:
404
+ return "".join(f"&#{ord(ch)};" for ch in self.emoji)
405
+
406
+
407
+ ConfluencePanel.from_class = {
408
+ "attention": ConfluencePanel("❗", "exclamation", "#F9F9F9"), # rST admonition
409
+ "caution": ConfluencePanel("❌", "x", "#FFEBE9"),
410
+ "danger": ConfluencePanel("☠️", "skull_crossbones", "#FFE5E5"), # rST admonition
411
+ "disclaimer": ConfluencePanel("❗", "exclamation", "#F9F9F9"), # GitLab
412
+ "error": ConfluencePanel("❌", "x", "#FFEBE9"), # rST admonition
413
+ "flag": ConfluencePanel("🚩", "triangular_flag_on_post", "#FDECEA"), # GitLab
414
+ "hint": ConfluencePanel("💡", "bulb", "#DAFBE1"), # rST admonition
415
+ "info": ConfluencePanel("ℹ️", "information_source", "#DDF4FF"),
416
+ "note": ConfluencePanel("📝", "pencil", "#DDF4FF"),
417
+ "tip": ConfluencePanel("💡", "bulb", "#DAFBE1"),
418
+ "important": ConfluencePanel("❗", "exclamation", "#FBEFFF"),
419
+ "warning": ConfluencePanel("⚠️", "warning", "#FFF8C5"),
420
+ }
421
+
422
+
357
423
  class ConfluenceStorageFormatConverter(NodeVisitor):
358
424
  "Transforms a plain HTML tree into Confluence Storage Format."
359
425
 
@@ -392,7 +458,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
392
458
  self.site_metadata = site_metadata
393
459
  self.page_metadata = page_metadata
394
460
 
395
- def _transform_heading(self, heading: ET._Element) -> None:
461
+ def _transform_heading(self, heading: ElementType) -> None:
396
462
  """
397
463
  Adds anchors to headings in the same document (if *heading anchors* is enabled).
398
464
 
@@ -428,7 +494,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
428
494
  anchor.tail = heading.text
429
495
  heading.text = None
430
496
 
431
- def _anchor_warn_or_raise(self, anchor: ET._Element, msg: str) -> None:
497
+ def _anchor_warn_or_raise(self, anchor: ElementType, msg: str) -> None:
432
498
  "Emit a warning or raise an exception when a path points to a resource that doesn't exist or is outside of the permitted hierarchy."
433
499
 
434
500
  if self.options.ignore_invalid_url:
@@ -440,7 +506,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
440
506
  else:
441
507
  raise DocumentError(msg)
442
508
 
443
- def _transform_link(self, anchor: ET._Element) -> Optional[ET._Element]:
509
+ def _transform_link(self, anchor: ElementType) -> Optional[ElementType]:
444
510
  """
445
511
  Transforms links (HTML anchor `<a>`).
446
512
 
@@ -493,7 +559,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
493
559
  else:
494
560
  return self._transform_attachment_link(anchor, absolute_path)
495
561
 
496
- def _transform_page_link(self, anchor: ET._Element, relative_url: ParseResult, absolute_path: Path) -> Optional[ET._Element]:
562
+ def _transform_page_link(self, anchor: ElementType, relative_url: ParseResult, absolute_path: Path) -> Optional[ElementType]:
497
563
  """
498
564
  Transforms links to other Markdown documents (Confluence pages).
499
565
  """
@@ -530,7 +596,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
530
596
  anchor.set("href", transformed_url.geturl())
531
597
  return None
532
598
 
533
- def _transform_attachment_link(self, anchor: ET._Element, absolute_path: Path) -> Optional[ET._Element]:
599
+ def _transform_attachment_link(self, anchor: ElementType, absolute_path: Path) -> Optional[ElementType]:
534
600
  """
535
601
  Transforms links to document binaries such as PDF, DOCX or XLSX.
536
602
  """
@@ -552,7 +618,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
552
618
  )
553
619
  return link_wrapper
554
620
 
555
- def _transform_status(self, color: str, caption: str) -> ET._Element:
621
+ def _transform_status(self, color: str, caption: str) -> ElementType:
556
622
  macro_id = str(uuid.uuid4())
557
623
  attributes = {
558
624
  AC_ATTR("name"): "status",
@@ -585,7 +651,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
585
651
  ),
586
652
  )
587
653
 
588
- def _transform_image(self, context: FormattingContext, image: ET._Element) -> ET._Element:
654
+ def _transform_image(self, context: FormattingContext, image: ElementType) -> ElementType:
589
655
  "Inserts an attached or external image."
590
656
 
591
657
  src = image.get("src")
@@ -601,7 +667,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
601
667
  height = image.get("height")
602
668
  pixel_width = int(width) if width is not None and width.isdecimal() else None
603
669
  pixel_height = int(height) if height is not None and height.isdecimal() else None
604
- attrs = ImageAttributes(context, pixel_width, pixel_height, alt, title, None)
670
+ attrs = ImageAttributes(
671
+ context, width=pixel_width, height=pixel_height, alt=alt, title=title, caption=None, alignment=ImageAlignment(self.options.alignment)
672
+ )
605
673
 
606
674
  if is_absolute_url(src):
607
675
  return self._transform_external_image(src, attrs)
@@ -621,10 +689,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
621
689
  else:
622
690
  return self._transform_attached_image(absolute_path, attrs)
623
691
 
624
- def _transform_external_image(self, url: str, attrs: ImageAttributes) -> ET._Element:
692
+ def _transform_external_image(self, url: str, attrs: ImageAttributes) -> ElementType:
625
693
  "Emits Confluence Storage Format XHTML for an external image."
626
694
 
627
- elements: list[ET._Element] = []
695
+ elements: list[ElementType] = []
628
696
  elements.append(
629
697
  RI_ELEM(
630
698
  "url",
@@ -661,7 +729,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
661
729
 
662
730
  return absolute_path
663
731
 
664
- def _transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
732
+ def _transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
665
733
  "Emits Confluence Storage Format XHTML for an attached raster or vector image."
666
734
 
667
735
  if self.options.prefer_raster and absolute_path.suffix == ".svg":
@@ -674,7 +742,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
674
742
  image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
675
743
  return self._create_attached_image(image_name, attrs)
676
744
 
677
- def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
745
+ def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
678
746
  "Emits Confluence Storage Format XHTML for a draw.io diagram."
679
747
 
680
748
  if not absolute_path.name.endswith(".drawio.xml") and not absolute_path.name.endswith(".drawio"):
@@ -691,7 +759,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
691
759
  image_filename = attachment_name(relative_path)
692
760
  return self._create_drawio(image_filename, attrs)
693
761
 
694
- def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
762
+ def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
695
763
  "Emits Confluence Storage Format XHTML for a draw.io diagram embedded in a PNG or SVG image."
696
764
 
697
765
  if not absolute_path.name.endswith(".drawio.png") and not absolute_path.name.endswith(".drawio.svg"):
@@ -707,10 +775,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
707
775
 
708
776
  return self._create_drawio(image_filename, attrs)
709
777
 
710
- def _create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ET._Element:
778
+ def _create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ElementType:
711
779
  "An image embedded into the page, linking to an attachment."
712
780
 
713
- elements: list[ET._Element] = []
781
+ elements: list[ElementType] = []
714
782
  elements.append(
715
783
  RI_ELEM(
716
784
  "attachment",
@@ -723,10 +791,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
723
791
 
724
792
  return AC_ELEM("image", attrs.as_dict(), *elements)
725
793
 
726
- def _create_drawio(self, filename: str, attrs: ImageAttributes) -> ET._Element:
794
+ def _create_drawio(self, filename: str, attrs: ImageAttributes) -> ElementType:
727
795
  "A draw.io diagram embedded into the page, linking to an attachment."
728
796
 
729
- parameters: list[ET._Element] = [
797
+ parameters: list[ElementType] = [
730
798
  AC_ELEM(
731
799
  "parameter",
732
800
  {AC_ATTR("name"): "diagramName"},
@@ -764,7 +832,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
764
832
  *parameters,
765
833
  )
766
834
 
767
- def _create_missing(self, path: Path, attrs: ImageAttributes) -> ET._Element:
835
+ def _create_missing(self, path: Path, attrs: ImageAttributes) -> ElementType:
768
836
  "A warning panel for a missing image."
769
837
 
770
838
  if attrs.context is FormattingContext.BLOCK:
@@ -792,7 +860,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
792
860
  else:
793
861
  return HTML.span({"style": "color: rgb(255,86,48);"}, "❌ ", HTML.code(path.as_posix()))
794
862
 
795
- def _transform_code_block(self, code: ET._Element) -> ET._Element:
863
+ def _transform_code_block(self, code: ElementType) -> ElementType:
796
864
  "Transforms a code block."
797
865
 
798
866
  if language_class := code.get("class"):
@@ -838,7 +906,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
838
906
  LOGGER.warning("Failed to extract Mermaid properties: %s", ex)
839
907
  return None
840
908
 
841
- def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
909
+ def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
842
910
  "Emits Confluence Storage Format XHTML for a Mermaid diagram read from an external file."
843
911
 
844
912
  if not absolute_path.name.endswith(".mmd") and not absolute_path.name.endswith(".mermaid"):
@@ -858,7 +926,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
858
926
  mermaid_filename = attachment_name(relative_path)
859
927
  return self._create_mermaid_embed(mermaid_filename)
860
928
 
861
- def _transform_fenced_mermaid(self, content: str) -> ET._Element:
929
+ def _transform_fenced_mermaid(self, content: str) -> ElementType:
862
930
  "Emits Confluence Storage Format XHTML for a Mermaid diagram defined in a fenced code block."
863
931
 
864
932
  if self.options.render_mermaid:
@@ -875,7 +943,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
875
943
  self.embedded_files[mermaid_filename] = EmbeddedFileData(mermaid_data)
876
944
  return self._create_mermaid_embed(mermaid_filename)
877
945
 
878
- def _create_mermaid_embed(self, filename: str) -> ET._Element:
946
+ def _create_mermaid_embed(self, filename: str) -> ElementType:
879
947
  "A Mermaid diagram, linking to an attachment that captures the Mermaid source."
880
948
 
881
949
  local_id = str(uuid.uuid4())
@@ -907,7 +975,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
907
975
  AC_ELEM("parameter", {AC_ATTR("name"): "revision"}, "1"),
908
976
  )
909
977
 
910
- def _transform_toc(self, code: ET._Element) -> ET._Element:
978
+ def _transform_toc(self, code: ElementType) -> ElementType:
911
979
  "Creates a table of contents, constructed from headings in the document."
912
980
 
913
981
  return AC_ELEM(
@@ -921,7 +989,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
921
989
  AC_ELEM("parameter", {AC_ATTR("name"): "style"}, "default"),
922
990
  )
923
991
 
924
- def _transform_listing(self, code: ET._Element) -> ET._Element:
992
+ def _transform_listing(self, code: ElementType) -> ElementType:
925
993
  "Creates a list of child pages."
926
994
 
927
995
  return AC_ELEM(
@@ -934,7 +1002,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
934
1002
  AC_ELEM("parameter", {AC_ATTR("name"): "allChildren"}, "true"),
935
1003
  )
936
1004
 
937
- def _transform_admonition(self, elem: ET._Element) -> ET._Element:
1005
+ def _transform_admonition(self, elem: ElementType) -> ElementType:
938
1006
  """
939
1007
  Creates an info, tip, note or warning panel from a Markdown admonition.
940
1008
 
@@ -947,45 +1015,51 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
947
1015
 
948
1016
  # <div class="admonition note">
949
1017
  class_list = elem.get("class", "").split(" ")
950
- class_name: Optional[str] = None
951
- if "info" in class_list:
952
- class_name = "info"
953
- elif "tip" in class_list:
954
- class_name = "tip"
955
- elif "note" in class_list:
956
- class_name = "note"
957
- elif "warning" in class_list:
958
- class_name = "warning"
959
-
960
- if class_name is None:
961
- raise DocumentError(f"unsupported admonition label: {class_list}")
1018
+ class_list.remove("admonition")
1019
+ if len(class_list) > 1:
1020
+ raise DocumentError(f"too many admonition types: {class_list}")
1021
+ elif len(class_list) < 1:
1022
+ raise DocumentError("missing specific admonition type")
1023
+ admonition = class_list[0]
962
1024
 
963
1025
  for e in elem:
964
1026
  self.visit(e)
965
1027
 
966
1028
  # <p class="admonition-title">Note</p>
967
1029
  if "admonition-title" in elem[0].get("class", "").split(" "):
968
- content = [
969
- AC_ELEM(
970
- "parameter",
971
- {AC_ATTR("name"): "title"},
972
- elem[0].text or "",
973
- ),
974
- AC_ELEM("rich-text-body", {}, *list(elem[1:])),
975
- ]
1030
+ content = [HTML.p(HTML.strong(elem[0].text or "")), *list(elem[1:])]
976
1031
  else:
977
- content = [AC_ELEM("rich-text-body", {}, *list(elem))]
1032
+ content = list(elem)
978
1033
 
979
- return AC_ELEM(
980
- "structured-macro",
981
- {
982
- AC_ATTR("name"): class_name,
983
- AC_ATTR("schema-version"): "1",
984
- },
985
- *content,
986
- )
1034
+ if self.options.use_panel:
1035
+ return self._transform_panel(content, admonition)
1036
+ else:
1037
+ admonition_to_csf = {
1038
+ "attention": "note",
1039
+ "caution": "warning",
1040
+ "danger": "warning",
1041
+ "error": "warning",
1042
+ "hint": "tip",
1043
+ "important": "note",
1044
+ "info": "info",
1045
+ "note": "info",
1046
+ "tip": "tip",
1047
+ "warning": "note",
1048
+ }
1049
+ class_name = admonition_to_csf.get(admonition)
1050
+ if class_name is None:
1051
+ raise DocumentError(f"unsupported admonition type: {admonition}")
987
1052
 
988
- def _transform_github_alert(self, blockquote: ET._Element) -> ET._Element:
1053
+ return AC_ELEM(
1054
+ "structured-macro",
1055
+ {
1056
+ AC_ATTR("name"): class_name,
1057
+ AC_ATTR("schema-version"): "1",
1058
+ },
1059
+ AC_ELEM("rich-text-body", {}, *content),
1060
+ )
1061
+
1062
+ def _transform_github_alert(self, blockquote: ElementType) -> ElementType:
989
1063
  """
990
1064
  Creates a GitHub-style panel, normally triggered with a block-quote starting with a capitalized string such as `[!TIP]`.
991
1065
  """
@@ -997,30 +1071,29 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
997
1071
  if content.text is None:
998
1072
  raise DocumentError("empty content")
999
1073
 
1000
- class_name: Optional[str] = None
1001
- skip = 0
1002
-
1003
1074
  pattern = re.compile(r"^\[!([A-Z]+)\]\s*")
1004
1075
  match = pattern.match(content.text)
1005
- if match:
1006
- skip = len(match.group(0))
1007
- alert = match.group(1)
1008
- if alert == "NOTE":
1009
- class_name = "note"
1010
- elif alert == "TIP":
1011
- class_name = "tip"
1012
- elif alert == "IMPORTANT":
1013
- class_name = "tip"
1014
- elif alert == "WARNING":
1015
- class_name = "warning"
1016
- elif alert == "CAUTION":
1017
- class_name = "warning"
1018
- else:
1076
+ if not match:
1077
+ raise DocumentError("not a GitHub alert")
1078
+
1079
+ # remove alert indicator prefix
1080
+ content.text = content.text[len(match.group(0)) :]
1081
+
1082
+ for e in blockquote:
1083
+ self.visit(e)
1084
+
1085
+ alert = match.group(1)
1086
+ if self.options.use_panel:
1087
+ return self._transform_panel(list(blockquote), alert.lower())
1088
+ else:
1089
+ alert_to_csf = {"NOTE": "info", "TIP": "tip", "IMPORTANT": "note", "WARNING": "note", "CAUTION": "warning"}
1090
+ class_name = alert_to_csf.get(alert)
1091
+ if class_name is None:
1019
1092
  raise DocumentError(f"unsupported GitHub alert: {alert}")
1020
1093
 
1021
- return self._transform_alert(blockquote, class_name, skip)
1094
+ return self._transform_alert(blockquote, class_name)
1022
1095
 
1023
- def _transform_gitlab_alert(self, blockquote: ET._Element) -> ET._Element:
1096
+ def _transform_gitlab_alert(self, blockquote: ElementType) -> ElementType:
1024
1097
  """
1025
1098
  Creates a classic GitLab-style panel.
1026
1099
 
@@ -1035,58 +1108,91 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1035
1108
  if content.text is None:
1036
1109
  raise DocumentError("empty content")
1037
1110
 
1038
- class_name: Optional[str] = None
1039
- skip = 0
1040
-
1041
1111
  pattern = re.compile(r"^(FLAG|NOTE|WARNING|DISCLAIMER):\s*")
1042
1112
  match = pattern.match(content.text)
1043
- if match:
1044
- skip = len(match.group(0))
1045
- alert = match.group(1)
1046
- if alert == "FLAG":
1047
- class_name = "note"
1048
- elif alert == "NOTE":
1049
- class_name = "note"
1050
- elif alert == "WARNING":
1051
- class_name = "warning"
1052
- elif alert == "DISCLAIMER":
1053
- class_name = "info"
1054
- else:
1113
+ if not match:
1114
+ raise DocumentError("not a GitLab alert")
1115
+
1116
+ # remove alert indicator prefix
1117
+ content.text = content.text[len(match.group(0)) :]
1118
+
1119
+ for e in blockquote:
1120
+ self.visit(e)
1121
+
1122
+ alert = match.group(1)
1123
+ if self.options.use_panel:
1124
+ return self._transform_panel(list(blockquote), alert.lower())
1125
+ else:
1126
+ alert_to_csf = {"FLAG": "note", "NOTE": "info", "WARNING": "note", "DISCLAIMER": "info"}
1127
+ class_name = alert_to_csf.get(alert)
1128
+ if class_name is None:
1055
1129
  raise DocumentError(f"unsupported GitLab alert: {alert}")
1056
1130
 
1057
- return self._transform_alert(blockquote, class_name, skip)
1131
+ return self._transform_alert(blockquote, class_name)
1058
1132
 
1059
- def _transform_alert(self, blockquote: ET._Element, class_name: Optional[str], skip: int) -> ET._Element:
1133
+ def _transform_alert(self, blockquote: ElementType, class_name: str) -> ElementType:
1060
1134
  """
1061
- Creates an info, tip, note or warning panel from a GitHub or GitLab alert.
1135
+ Creates an `info`, `tip`, `note` or `warning` panel from a GitHub or GitLab alert.
1136
+
1137
+ Transforms GitHub alert or GitLab alert syntax into one of the Confluence structured macros `info`, `tip`, `note`, or `warning`.
1138
+
1139
+ Confusingly, these structured macros have completely different alternate names on the UI, namely: *Info*, *Success*, *Warning* and *Error* (in this
1140
+ order). In other words, to get what is shown as *Error* on the UI, you have to pass `warning` in CSF, and to get *Success*, you have to pass `tip`.
1062
1141
 
1063
- Transforms GitHub alert or GitLab alert syntax into one of the Confluence structured macros *info*, *tip*, *note*, or *warning*.
1142
+ Confluence UI also has an additional panel type called *Note*. *Note* is not a structured macro but corresponds to a different element tree, wrapped in
1143
+ an element `ac:adf-extension`:
1144
+
1145
+ ```
1146
+ <ac:adf-node type="panel">
1147
+ <ac:adf-attribute key="panel-type">note</ac:adf-attribute>
1148
+ <ac:adf-content>
1149
+ <p><strong>A note</strong></p>
1150
+ <p>This is a panel showing a note.</p>
1151
+ </ac:adf-content>
1152
+ </ac:adf-node>
1153
+ ```
1154
+
1155
+ As of today, *md2conf* does not generate `ac:adf-extension` output, including *Note* and *Custom panel* (which shows an emoji selected by the user).
1156
+
1157
+ :param blockquote: HTML element tree to transform to Confluence Storage Format (CSF).
1158
+ :param class_name: Corresponds to `name` attribute for CSF `structured-macro`.
1064
1159
 
1065
1160
  :see: https://docs.github.com/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
1066
1161
  :see: https://docs.gitlab.com/ee/development/documentation/styleguide/#alert-boxes
1067
1162
  """
1068
1163
 
1069
- content = blockquote[0]
1070
- if content.text is None:
1071
- raise DocumentError("empty content")
1164
+ return AC_ELEM(
1165
+ "structured-macro",
1166
+ {
1167
+ AC_ATTR("name"): class_name,
1168
+ AC_ATTR("schema-version"): "1",
1169
+ },
1170
+ AC_ELEM("rich-text-body", {}, *list(blockquote)),
1171
+ )
1072
1172
 
1073
- if class_name is None:
1074
- raise DocumentError("not an alert")
1173
+ def _transform_panel(self, content: list[ElementType], class_name: str) -> ElementType:
1174
+ "Transforms a blockquote into a themed panel."
1075
1175
 
1076
- for e in blockquote:
1077
- self.visit(e)
1176
+ panel = ConfluencePanel.from_class.get(class_name)
1177
+ if panel is None:
1178
+ raise DocumentError(f"unsupported panel class: {class_name}")
1078
1179
 
1079
- content.text = content.text[skip:]
1180
+ macro_id = str(uuid.uuid4())
1080
1181
  return AC_ELEM(
1081
1182
  "structured-macro",
1082
1183
  {
1083
- AC_ATTR("name"): class_name,
1184
+ AC_ATTR("name"): "panel",
1084
1185
  AC_ATTR("schema-version"): "1",
1186
+ AC_ATTR("macro-id"): macro_id,
1085
1187
  },
1086
- AC_ELEM("rich-text-body", {}, *list(blockquote)),
1188
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIcon"}, f":{panel.emoji_shortname}:"),
1189
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIconId"}, panel.emoji_unicode),
1190
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIconText"}, panel.emoji),
1191
+ AC_ELEM("parameter", {AC_ATTR("name"): "bgColor"}, panel.background_color),
1192
+ AC_ELEM("rich-text-body", {}, *content),
1087
1193
  )
1088
1194
 
1089
- def _transform_collapsed(self, details: ET._Element) -> ET._Element:
1195
+ def _transform_collapsed(self, details: ElementType) -> ElementType:
1090
1196
  """
1091
1197
  Creates a collapsed section.
1092
1198
 
@@ -1135,7 +1241,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1135
1241
  AC_ELEM("rich-text-body", {}, *list(details)),
1136
1242
  )
1137
1243
 
1138
- def _transform_emoji(self, elem: ET._Element) -> ET._Element:
1244
+ def _transform_emoji(self, elem: ElementType) -> ElementType:
1139
1245
  """
1140
1246
  Inserts an inline emoji character.
1141
1247
  """
@@ -1159,7 +1265,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1159
1265
  },
1160
1266
  )
1161
1267
 
1162
- def _transform_mark(self, mark: ET._Element) -> ET._Element:
1268
+ def _transform_mark(self, mark: ElementType) -> ElementType:
1163
1269
  """
1164
1270
  Adds inline highlighting to text.
1165
1271
  """
@@ -1174,7 +1280,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1174
1280
  span.text = mark.text
1175
1281
  return span
1176
1282
 
1177
- def _transform_latex(self, elem: ET._Element, context: FormattingContext) -> ET._Element:
1283
+ def _transform_latex(self, elem: ElementType, context: FormattingContext) -> ElementType:
1178
1284
  """
1179
1285
  Creates an image rendering of a LaTeX formula with Matplotlib.
1180
1286
  """
@@ -1187,7 +1293,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1187
1293
  if self.options.diagram_output_format == "png":
1188
1294
  width, height = get_png_dimensions(data=image_data)
1189
1295
  image_data = remove_png_chunks(["pHYs"], source_data=image_data)
1190
- attrs = ImageAttributes(context, width, height, content, None, "")
1296
+ attrs = ImageAttributes(context, width=width, height=height, alt=content, title=None, caption="", alignment=ImageAlignment(self.options.alignment))
1191
1297
  else:
1192
1298
  attrs = ImageAttributes.empty(context)
1193
1299
 
@@ -1197,7 +1303,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1197
1303
  image = self._create_attached_image(image_filename, attrs)
1198
1304
  return image
1199
1305
 
1200
- def _transform_inline_math(self, elem: ET._Element) -> ET._Element:
1306
+ def _transform_inline_math(self, elem: ElementType) -> ElementType:
1201
1307
  """
1202
1308
  Creates an inline LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
1203
1309
 
@@ -1228,11 +1334,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1228
1334
  {AC_ATTR("name"): "body"},
1229
1335
  content,
1230
1336
  ),
1231
- AC_ELEM("parameter", {AC_ATTR("name"): "align"}, "center"),
1337
+ AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
1232
1338
  )
1233
1339
  return macro
1234
1340
 
1235
- def _transform_block_math(self, elem: ET._Element) -> ET._Element:
1341
+ def _transform_block_math(self, elem: ElementType) -> ElementType:
1236
1342
  """
1237
1343
  Creates a block-level LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
1238
1344
 
@@ -1265,10 +1371,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1265
1371
  {AC_ATTR("name"): "body"},
1266
1372
  content,
1267
1373
  ),
1268
- AC_ELEM("parameter", {AC_ATTR("name"): "align"}, "center"),
1374
+ AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
1269
1375
  )
1270
1376
 
1271
- def _transform_footnote_ref(self, elem: ET._Element) -> None:
1377
+ def _transform_footnote_ref(self, elem: ElementType) -> None:
1272
1378
  """
1273
1379
  Transforms a footnote reference.
1274
1380
 
@@ -1325,7 +1431,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1325
1431
  elem.append(ref_anchor)
1326
1432
  elem.append(def_link)
1327
1433
 
1328
- def _transform_footnote_def(self, elem: ET._Element) -> None:
1434
+ def _transform_footnote_def(self, elem: ElementType) -> None:
1329
1435
  """
1330
1436
  Transforms the footnote definition block.
1331
1437
 
@@ -1399,7 +1505,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1399
1505
  paragraph.text = None
1400
1506
  paragraph.append(ref_link)
1401
1507
 
1402
- def _transform_tasklist(self, elem: ET._Element) -> ET._Element:
1508
+ def _transform_tasklist(self, elem: ElementType) -> ElementType:
1403
1509
  """
1404
1510
  Transforms a list of tasks into an action widget.
1405
1511
 
@@ -1415,7 +1521,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1415
1521
  if not element_text_starts_with_any(item, ["[ ]", "[x]", "[X]"]):
1416
1522
  raise DocumentError("expected: each `<li>` in a task list starting with [ ] or [x]")
1417
1523
 
1418
- tasks: list[ET._Element] = []
1524
+ tasks: list[ElementType] = []
1419
1525
  for index, item in enumerate(elem, start=1):
1420
1526
  if item.text is None:
1421
1527
  raise NotImplementedError("pre-condition check not exhaustive")
@@ -1444,7 +1550,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1444
1550
  return AC_ELEM("task-list", {}, *tasks)
1445
1551
 
1446
1552
  @override
1447
- def transform(self, child: ET._Element) -> Optional[ET._Element]:
1553
+ def transform(self, child: ElementType) -> Optional[ElementType]:
1448
1554
  """
1449
1555
  Transforms an HTML element tree obtained from a Markdown document into a Confluence Storage Format element tree.
1450
1556
  """
@@ -1637,7 +1743,7 @@ class ConfluenceDocument:
1637
1743
  embedded_files: dict[str, EmbeddedFileData]
1638
1744
 
1639
1745
  options: ConfluenceDocumentOptions
1640
- root: ET._Element
1746
+ root: ElementType
1641
1747
 
1642
1748
  @classmethod
1643
1749
  def create(
@@ -1683,10 +1789,10 @@ class ConfluenceDocument:
1683
1789
  lines.append(f"[STATUS-{color.upper()}]: {data_uri}")
1684
1790
  lines.append(document.text)
1685
1791
 
1686
- # convert to HTML
1792
+ # parse Markdown document and convert to HTML
1687
1793
  html = markdown_to_html("\n".join(lines))
1688
1794
 
1689
- # parse Markdown document
1795
+ # modify HTML as necessary
1690
1796
  if self.options.generated_by is not None:
1691
1797
  generated_by = document.generated_by or self.options.generated_by
1692
1798
  else:
@@ -1704,26 +1810,32 @@ class ConfluenceDocument:
1704
1810
  else:
1705
1811
  content = [html]
1706
1812
 
1813
+ # parse HTML into element tree
1707
1814
  try:
1708
1815
  self.root = elements_from_strings(content)
1709
1816
  except ParseError as ex:
1710
1817
  raise ConversionError(path) from ex
1711
1818
 
1712
- converter = ConfluenceStorageFormatConverter(
1713
- ConfluenceConverterOptions(**{field.name: getattr(self.options, field.name) for field in dataclasses.fields(ConfluenceConverterOptions)}),
1714
- path,
1715
- root_dir,
1716
- site_metadata,
1717
- page_metadata,
1819
+ # configure HTML-to-Confluence converter
1820
+ converter_options = ConfluenceConverterOptions(
1821
+ **{field.name: getattr(self.options, field.name) for field in dataclasses.fields(ConfluenceConverterOptions)}
1718
1822
  )
1823
+ if document.alignment is not None:
1824
+ converter_options.alignment = document.alignment
1825
+ converter = ConfluenceStorageFormatConverter(converter_options, path, root_dir, site_metadata, page_metadata)
1826
+
1827
+ # execute HTML-to-Confluence converter
1719
1828
  try:
1720
1829
  converter.visit(self.root)
1721
1830
  except DocumentError as ex:
1722
1831
  raise ConversionError(path) from ex
1832
+
1833
+ # extract information discovered by converter
1723
1834
  self.links = converter.links
1724
1835
  self.images = converter.images
1725
1836
  self.embedded_files = converter.embedded_files
1726
1837
 
1838
+ # assign global properties for document
1727
1839
  self.title = document.title or converter.toc.get_title()
1728
1840
  self.labels = document.tags
1729
1841
  self.properties = document.properties