markdown-to-confluence 0.4.6__py3-none-any.whl → 0.4.8__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", "var(--ds-background-accent-gray-subtlest)"), # rST admonition
409
+ "caution": ConfluencePanel("❌", "x", "var(--ds-background-accent-orange-subtlest)"),
410
+ "danger": ConfluencePanel("☠️", "skull_crossbones", "var(--ds-background-accent-red-subtlest)"), # rST admonition
411
+ "disclaimer": ConfluencePanel("❗", "exclamation", "var(--ds-background-accent-gray-subtlest)"), # GitLab
412
+ "error": ConfluencePanel("❌", "x", "var(--ds-background-accent-red-subtlest)"), # rST admonition
413
+ "flag": ConfluencePanel("🚩", "triangular_flag_on_post", "var(--ds-background-accent-orange-subtlest"), # GitLab
414
+ "hint": ConfluencePanel("💡", "bulb", "var(--ds-background-accent-green-subtlest)"), # rST admonition
415
+ "info": ConfluencePanel("ℹ️", "information_source", "var(--ds-background-accent-blue-subtlest)"),
416
+ "note": ConfluencePanel("📝", "pencil", "var(--ds-background-accent-teal-subtlest)"),
417
+ "tip": ConfluencePanel("💡", "bulb", "var(--ds-background-accent-green-subtlest)"),
418
+ "important": ConfluencePanel("❗", "exclamation", "var(--ds-background-accent-purple-subtlest)"),
419
+ "warning": ConfluencePanel("⚠️", "warning", "var(--ds-background-accent-yellow-subtlest)"),
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"},
@@ -749,6 +817,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
749
817
  str(attrs.height),
750
818
  ),
751
819
  )
820
+ if attrs.alignment is ImageAlignment.CENTER:
821
+ parameters.append(
822
+ AC_ELEM(
823
+ "parameter",
824
+ {AC_ATTR("name"): "pCenter"},
825
+ str(1),
826
+ ),
827
+ )
752
828
 
753
829
  local_id = str(uuid.uuid4())
754
830
  macro_id = str(uuid.uuid4())
@@ -764,7 +840,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
764
840
  *parameters,
765
841
  )
766
842
 
767
- def _create_missing(self, path: Path, attrs: ImageAttributes) -> ET._Element:
843
+ def _create_missing(self, path: Path, attrs: ImageAttributes) -> ElementType:
768
844
  "A warning panel for a missing image."
769
845
 
770
846
  if attrs.context is FormattingContext.BLOCK:
@@ -792,7 +868,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
792
868
  else:
793
869
  return HTML.span({"style": "color: rgb(255,86,48);"}, "❌ ", HTML.code(path.as_posix()))
794
870
 
795
- def _transform_code_block(self, code: ET._Element) -> ET._Element:
871
+ def _transform_code_block(self, code: ElementType) -> ElementType:
796
872
  "Transforms a code block."
797
873
 
798
874
  if language_class := code.get("class"):
@@ -838,7 +914,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
838
914
  LOGGER.warning("Failed to extract Mermaid properties: %s", ex)
839
915
  return None
840
916
 
841
- def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
917
+ def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
842
918
  "Emits Confluence Storage Format XHTML for a Mermaid diagram read from an external file."
843
919
 
844
920
  if not absolute_path.name.endswith(".mmd") and not absolute_path.name.endswith(".mermaid"):
@@ -858,7 +934,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
858
934
  mermaid_filename = attachment_name(relative_path)
859
935
  return self._create_mermaid_embed(mermaid_filename)
860
936
 
861
- def _transform_fenced_mermaid(self, content: str) -> ET._Element:
937
+ def _transform_fenced_mermaid(self, content: str) -> ElementType:
862
938
  "Emits Confluence Storage Format XHTML for a Mermaid diagram defined in a fenced code block."
863
939
 
864
940
  if self.options.render_mermaid:
@@ -875,7 +951,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
875
951
  self.embedded_files[mermaid_filename] = EmbeddedFileData(mermaid_data)
876
952
  return self._create_mermaid_embed(mermaid_filename)
877
953
 
878
- def _create_mermaid_embed(self, filename: str) -> ET._Element:
954
+ def _create_mermaid_embed(self, filename: str) -> ElementType:
879
955
  "A Mermaid diagram, linking to an attachment that captures the Mermaid source."
880
956
 
881
957
  local_id = str(uuid.uuid4())
@@ -907,7 +983,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
907
983
  AC_ELEM("parameter", {AC_ATTR("name"): "revision"}, "1"),
908
984
  )
909
985
 
910
- def _transform_toc(self, code: ET._Element) -> ET._Element:
986
+ def _transform_toc(self, code: ElementType) -> ElementType:
911
987
  "Creates a table of contents, constructed from headings in the document."
912
988
 
913
989
  return AC_ELEM(
@@ -921,7 +997,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
921
997
  AC_ELEM("parameter", {AC_ATTR("name"): "style"}, "default"),
922
998
  )
923
999
 
924
- def _transform_listing(self, code: ET._Element) -> ET._Element:
1000
+ def _transform_listing(self, code: ElementType) -> ElementType:
925
1001
  "Creates a list of child pages."
926
1002
 
927
1003
  return AC_ELEM(
@@ -934,7 +1010,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
934
1010
  AC_ELEM("parameter", {AC_ATTR("name"): "allChildren"}, "true"),
935
1011
  )
936
1012
 
937
- def _transform_admonition(self, elem: ET._Element) -> ET._Element:
1013
+ def _transform_admonition(self, elem: ElementType) -> ElementType:
938
1014
  """
939
1015
  Creates an info, tip, note or warning panel from a Markdown admonition.
940
1016
 
@@ -947,45 +1023,51 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
947
1023
 
948
1024
  # <div class="admonition note">
949
1025
  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}")
1026
+ class_list.remove("admonition")
1027
+ if len(class_list) > 1:
1028
+ raise DocumentError(f"too many admonition types: {class_list}")
1029
+ elif len(class_list) < 1:
1030
+ raise DocumentError("missing specific admonition type")
1031
+ admonition = class_list[0]
962
1032
 
963
1033
  for e in elem:
964
1034
  self.visit(e)
965
1035
 
966
1036
  # <p class="admonition-title">Note</p>
967
1037
  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
- ]
1038
+ content = [HTML.p(HTML.strong(elem[0].text or "")), *list(elem[1:])]
976
1039
  else:
977
- content = [AC_ELEM("rich-text-body", {}, *list(elem))]
1040
+ content = list(elem)
978
1041
 
979
- return AC_ELEM(
980
- "structured-macro",
981
- {
982
- AC_ATTR("name"): class_name,
983
- AC_ATTR("schema-version"): "1",
984
- },
985
- *content,
986
- )
1042
+ if self.options.use_panel:
1043
+ return self._transform_panel(content, admonition)
1044
+ else:
1045
+ admonition_to_csf = {
1046
+ "attention": "note",
1047
+ "caution": "warning",
1048
+ "danger": "warning",
1049
+ "error": "warning",
1050
+ "hint": "tip",
1051
+ "important": "note",
1052
+ "info": "info",
1053
+ "note": "info",
1054
+ "tip": "tip",
1055
+ "warning": "note",
1056
+ }
1057
+ class_name = admonition_to_csf.get(admonition)
1058
+ if class_name is None:
1059
+ raise DocumentError(f"unsupported admonition type: {admonition}")
1060
+
1061
+ return AC_ELEM(
1062
+ "structured-macro",
1063
+ {
1064
+ AC_ATTR("name"): class_name,
1065
+ AC_ATTR("schema-version"): "1",
1066
+ },
1067
+ AC_ELEM("rich-text-body", {}, *content),
1068
+ )
987
1069
 
988
- def _transform_github_alert(self, blockquote: ET._Element) -> ET._Element:
1070
+ def _transform_github_alert(self, blockquote: ElementType) -> ElementType:
989
1071
  """
990
1072
  Creates a GitHub-style panel, normally triggered with a block-quote starting with a capitalized string such as `[!TIP]`.
991
1073
  """
@@ -997,30 +1079,29 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
997
1079
  if content.text is None:
998
1080
  raise DocumentError("empty content")
999
1081
 
1000
- class_name: Optional[str] = None
1001
- skip = 0
1002
-
1003
1082
  pattern = re.compile(r"^\[!([A-Z]+)\]\s*")
1004
1083
  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:
1084
+ if not match:
1085
+ raise DocumentError("not a GitHub alert")
1086
+
1087
+ # remove alert indicator prefix
1088
+ content.text = content.text[len(match.group(0)) :]
1089
+
1090
+ for e in blockquote:
1091
+ self.visit(e)
1092
+
1093
+ alert = match.group(1)
1094
+ if self.options.use_panel:
1095
+ return self._transform_panel(list(blockquote), alert.lower())
1096
+ else:
1097
+ alert_to_csf = {"NOTE": "info", "TIP": "tip", "IMPORTANT": "note", "WARNING": "note", "CAUTION": "warning"}
1098
+ class_name = alert_to_csf.get(alert)
1099
+ if class_name is None:
1019
1100
  raise DocumentError(f"unsupported GitHub alert: {alert}")
1020
1101
 
1021
- return self._transform_alert(blockquote, class_name, skip)
1102
+ return self._transform_alert(blockquote, class_name)
1022
1103
 
1023
- def _transform_gitlab_alert(self, blockquote: ET._Element) -> ET._Element:
1104
+ def _transform_gitlab_alert(self, blockquote: ElementType) -> ElementType:
1024
1105
  """
1025
1106
  Creates a classic GitLab-style panel.
1026
1107
 
@@ -1035,58 +1116,91 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1035
1116
  if content.text is None:
1036
1117
  raise DocumentError("empty content")
1037
1118
 
1038
- class_name: Optional[str] = None
1039
- skip = 0
1040
-
1041
1119
  pattern = re.compile(r"^(FLAG|NOTE|WARNING|DISCLAIMER):\s*")
1042
1120
  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:
1121
+ if not match:
1122
+ raise DocumentError("not a GitLab alert")
1123
+
1124
+ # remove alert indicator prefix
1125
+ content.text = content.text[len(match.group(0)) :]
1126
+
1127
+ for e in blockquote:
1128
+ self.visit(e)
1129
+
1130
+ alert = match.group(1)
1131
+ if self.options.use_panel:
1132
+ return self._transform_panel(list(blockquote), alert.lower())
1133
+ else:
1134
+ alert_to_csf = {"FLAG": "note", "NOTE": "info", "WARNING": "note", "DISCLAIMER": "info"}
1135
+ class_name = alert_to_csf.get(alert)
1136
+ if class_name is None:
1055
1137
  raise DocumentError(f"unsupported GitLab alert: {alert}")
1056
1138
 
1057
- return self._transform_alert(blockquote, class_name, skip)
1139
+ return self._transform_alert(blockquote, class_name)
1058
1140
 
1059
- def _transform_alert(self, blockquote: ET._Element, class_name: Optional[str], skip: int) -> ET._Element:
1141
+ def _transform_alert(self, blockquote: ElementType, class_name: str) -> ElementType:
1060
1142
  """
1061
- Creates an info, tip, note or warning panel from a GitHub or GitLab alert.
1143
+ Creates an `info`, `tip`, `note` or `warning` panel from a GitHub or GitLab alert.
1144
+
1145
+ Transforms GitHub alert or GitLab alert syntax into one of the Confluence structured macros `info`, `tip`, `note`, or `warning`.
1146
+
1147
+ Confusingly, these structured macros have completely different alternate names on the UI, namely: *Info*, *Success*, *Warning* and *Error* (in this
1148
+ 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`.
1149
+
1150
+ 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
1151
+ an element `ac:adf-extension`:
1152
+
1153
+ ```
1154
+ <ac:adf-node type="panel">
1155
+ <ac:adf-attribute key="panel-type">note</ac:adf-attribute>
1156
+ <ac:adf-content>
1157
+ <p><strong>A note</strong></p>
1158
+ <p>This is a panel showing a note.</p>
1159
+ </ac:adf-content>
1160
+ </ac:adf-node>
1161
+ ```
1062
1162
 
1063
- Transforms GitHub alert or GitLab alert syntax into one of the Confluence structured macros *info*, *tip*, *note*, or *warning*.
1163
+ As of today, *md2conf* does not generate `ac:adf-extension` output, including *Note* and *Custom panel* (which shows an emoji selected by the user).
1164
+
1165
+ :param blockquote: HTML element tree to transform to Confluence Storage Format (CSF).
1166
+ :param class_name: Corresponds to `name` attribute for CSF `structured-macro`.
1064
1167
 
1065
1168
  :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
1169
  :see: https://docs.gitlab.com/ee/development/documentation/styleguide/#alert-boxes
1067
1170
  """
1068
1171
 
1069
- content = blockquote[0]
1070
- if content.text is None:
1071
- raise DocumentError("empty content")
1172
+ return AC_ELEM(
1173
+ "structured-macro",
1174
+ {
1175
+ AC_ATTR("name"): class_name,
1176
+ AC_ATTR("schema-version"): "1",
1177
+ },
1178
+ AC_ELEM("rich-text-body", {}, *list(blockquote)),
1179
+ )
1072
1180
 
1073
- if class_name is None:
1074
- raise DocumentError("not an alert")
1181
+ def _transform_panel(self, content: list[ElementType], class_name: str) -> ElementType:
1182
+ "Transforms a blockquote into a themed panel."
1075
1183
 
1076
- for e in blockquote:
1077
- self.visit(e)
1184
+ panel = ConfluencePanel.from_class.get(class_name)
1185
+ if panel is None:
1186
+ raise DocumentError(f"unsupported panel class: {class_name}")
1078
1187
 
1079
- content.text = content.text[skip:]
1188
+ macro_id = str(uuid.uuid4())
1080
1189
  return AC_ELEM(
1081
1190
  "structured-macro",
1082
1191
  {
1083
- AC_ATTR("name"): class_name,
1192
+ AC_ATTR("name"): "panel",
1084
1193
  AC_ATTR("schema-version"): "1",
1194
+ AC_ATTR("macro-id"): macro_id,
1085
1195
  },
1086
- AC_ELEM("rich-text-body", {}, *list(blockquote)),
1196
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIcon"}, f":{panel.emoji_shortname}:"),
1197
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIconId"}, panel.emoji_unicode),
1198
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIconText"}, panel.emoji),
1199
+ AC_ELEM("parameter", {AC_ATTR("name"): "bgColor"}, panel.background_color),
1200
+ AC_ELEM("rich-text-body", {}, *content),
1087
1201
  )
1088
1202
 
1089
- def _transform_collapsed(self, details: ET._Element) -> ET._Element:
1203
+ def _transform_collapsed(self, details: ElementType) -> ElementType:
1090
1204
  """
1091
1205
  Creates a collapsed section.
1092
1206
 
@@ -1135,7 +1249,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1135
1249
  AC_ELEM("rich-text-body", {}, *list(details)),
1136
1250
  )
1137
1251
 
1138
- def _transform_emoji(self, elem: ET._Element) -> ET._Element:
1252
+ def _transform_emoji(self, elem: ElementType) -> ElementType:
1139
1253
  """
1140
1254
  Inserts an inline emoji character.
1141
1255
  """
@@ -1159,7 +1273,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1159
1273
  },
1160
1274
  )
1161
1275
 
1162
- def _transform_mark(self, mark: ET._Element) -> ET._Element:
1276
+ def _transform_mark(self, mark: ElementType) -> ElementType:
1163
1277
  """
1164
1278
  Adds inline highlighting to text.
1165
1279
  """
@@ -1174,7 +1288,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1174
1288
  span.text = mark.text
1175
1289
  return span
1176
1290
 
1177
- def _transform_latex(self, elem: ET._Element, context: FormattingContext) -> ET._Element:
1291
+ def _transform_latex(self, elem: ElementType, context: FormattingContext) -> ElementType:
1178
1292
  """
1179
1293
  Creates an image rendering of a LaTeX formula with Matplotlib.
1180
1294
  """
@@ -1187,7 +1301,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1187
1301
  if self.options.diagram_output_format == "png":
1188
1302
  width, height = get_png_dimensions(data=image_data)
1189
1303
  image_data = remove_png_chunks(["pHYs"], source_data=image_data)
1190
- attrs = ImageAttributes(context, width, height, content, None, "")
1304
+ attrs = ImageAttributes(context, width=width, height=height, alt=content, title=None, caption="", alignment=ImageAlignment(self.options.alignment))
1191
1305
  else:
1192
1306
  attrs = ImageAttributes.empty(context)
1193
1307
 
@@ -1197,7 +1311,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1197
1311
  image = self._create_attached_image(image_filename, attrs)
1198
1312
  return image
1199
1313
 
1200
- def _transform_inline_math(self, elem: ET._Element) -> ET._Element:
1314
+ def _transform_inline_math(self, elem: ElementType) -> ElementType:
1201
1315
  """
1202
1316
  Creates an inline LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
1203
1317
 
@@ -1228,11 +1342,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1228
1342
  {AC_ATTR("name"): "body"},
1229
1343
  content,
1230
1344
  ),
1231
- AC_ELEM("parameter", {AC_ATTR("name"): "align"}, "center"),
1345
+ AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
1232
1346
  )
1233
1347
  return macro
1234
1348
 
1235
- def _transform_block_math(self, elem: ET._Element) -> ET._Element:
1349
+ def _transform_block_math(self, elem: ElementType) -> ElementType:
1236
1350
  """
1237
1351
  Creates a block-level LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
1238
1352
 
@@ -1265,10 +1379,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1265
1379
  {AC_ATTR("name"): "body"},
1266
1380
  content,
1267
1381
  ),
1268
- AC_ELEM("parameter", {AC_ATTR("name"): "align"}, "center"),
1382
+ AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
1269
1383
  )
1270
1384
 
1271
- def _transform_footnote_ref(self, elem: ET._Element) -> None:
1385
+ def _transform_footnote_ref(self, elem: ElementType) -> None:
1272
1386
  """
1273
1387
  Transforms a footnote reference.
1274
1388
 
@@ -1325,7 +1439,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1325
1439
  elem.append(ref_anchor)
1326
1440
  elem.append(def_link)
1327
1441
 
1328
- def _transform_footnote_def(self, elem: ET._Element) -> None:
1442
+ def _transform_footnote_def(self, elem: ElementType) -> None:
1329
1443
  """
1330
1444
  Transforms the footnote definition block.
1331
1445
 
@@ -1399,7 +1513,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1399
1513
  paragraph.text = None
1400
1514
  paragraph.append(ref_link)
1401
1515
 
1402
- def _transform_tasklist(self, elem: ET._Element) -> ET._Element:
1516
+ def _transform_tasklist(self, elem: ElementType) -> ElementType:
1403
1517
  """
1404
1518
  Transforms a list of tasks into an action widget.
1405
1519
 
@@ -1415,7 +1529,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1415
1529
  if not element_text_starts_with_any(item, ["[ ]", "[x]", "[X]"]):
1416
1530
  raise DocumentError("expected: each `<li>` in a task list starting with [ ] or [x]")
1417
1531
 
1418
- tasks: list[ET._Element] = []
1532
+ tasks: list[ElementType] = []
1419
1533
  for index, item in enumerate(elem, start=1):
1420
1534
  if item.text is None:
1421
1535
  raise NotImplementedError("pre-condition check not exhaustive")
@@ -1444,7 +1558,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1444
1558
  return AC_ELEM("task-list", {}, *tasks)
1445
1559
 
1446
1560
  @override
1447
- def transform(self, child: ET._Element) -> Optional[ET._Element]:
1561
+ def transform(self, child: ElementType) -> Optional[ElementType]:
1448
1562
  """
1449
1563
  Transforms an HTML element tree obtained from a Markdown document into a Confluence Storage Format element tree.
1450
1564
  """
@@ -1544,7 +1658,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1544
1658
  # <li>[x] ...</li>
1545
1659
  # </ul>
1546
1660
  elif child.tag == "ul":
1547
- if len(child) > 0 and element_text_starts_with_any(child[0], ["[ ]", "[x]", "[X]"]):
1661
+ if len(child) > 0 and all(element_text_starts_with_any(item, ["[ ]", "[x]", "[X]"]) for item in child):
1548
1662
  return self._transform_tasklist(child)
1549
1663
 
1550
1664
  return None
@@ -1637,7 +1751,7 @@ class ConfluenceDocument:
1637
1751
  embedded_files: dict[str, EmbeddedFileData]
1638
1752
 
1639
1753
  options: ConfluenceDocumentOptions
1640
- root: ET._Element
1754
+ root: ElementType
1641
1755
 
1642
1756
  @classmethod
1643
1757
  def create(
@@ -1683,10 +1797,10 @@ class ConfluenceDocument:
1683
1797
  lines.append(f"[STATUS-{color.upper()}]: {data_uri}")
1684
1798
  lines.append(document.text)
1685
1799
 
1686
- # convert to HTML
1800
+ # parse Markdown document and convert to HTML
1687
1801
  html = markdown_to_html("\n".join(lines))
1688
1802
 
1689
- # parse Markdown document
1803
+ # modify HTML as necessary
1690
1804
  if self.options.generated_by is not None:
1691
1805
  generated_by = document.generated_by or self.options.generated_by
1692
1806
  else:
@@ -1704,26 +1818,32 @@ class ConfluenceDocument:
1704
1818
  else:
1705
1819
  content = [html]
1706
1820
 
1821
+ # parse HTML into element tree
1707
1822
  try:
1708
1823
  self.root = elements_from_strings(content)
1709
1824
  except ParseError as ex:
1710
1825
  raise ConversionError(path) from ex
1711
1826
 
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,
1827
+ # configure HTML-to-Confluence converter
1828
+ converter_options = ConfluenceConverterOptions(
1829
+ **{field.name: getattr(self.options, field.name) for field in dataclasses.fields(ConfluenceConverterOptions)}
1718
1830
  )
1831
+ if document.alignment is not None:
1832
+ converter_options.alignment = document.alignment
1833
+ converter = ConfluenceStorageFormatConverter(converter_options, path, root_dir, site_metadata, page_metadata)
1834
+
1835
+ # execute HTML-to-Confluence converter
1719
1836
  try:
1720
1837
  converter.visit(self.root)
1721
1838
  except DocumentError as ex:
1722
1839
  raise ConversionError(path) from ex
1840
+
1841
+ # extract information discovered by converter
1723
1842
  self.links = converter.links
1724
1843
  self.images = converter.images
1725
1844
  self.embedded_files = converter.embedded_files
1726
1845
 
1846
+ # assign global properties for document
1727
1847
  self.title = document.title or converter.toc.get_title()
1728
1848
  self.labels = document.tags
1729
1849
  self.properties = document.properties