markdown-to-confluence 0.4.5__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.
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.7.dist-info}/METADATA +38 -13
- markdown_to_confluence-0.4.7.dist-info/RECORD +34 -0
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +34 -36
- md2conf/api.py +121 -43
- md2conf/converter.py +312 -165
- md2conf/csf.py +10 -8
- md2conf/domain.py +4 -0
- md2conf/drawio.py +5 -3
- md2conf/emoticon.py +22 -0
- md2conf/latex.py +2 -2
- md2conf/matcher.py +1 -3
- md2conf/mermaid.py +18 -3
- md2conf/processor.py +2 -2
- md2conf/{application.py → publisher.py} +15 -12
- md2conf/scanner.py +57 -3
- md2conf/xml.py +8 -5
- markdown_to_confluence-0.4.5.dist-info/RECORD +0 -33
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.7.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.7.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.7.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.7.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.4.5.dist-info → markdown_to_confluence-0.4.7.dist-info}/zip-safe +0 -0
- /md2conf/{properties.py → environment.py} +0 -0
md2conf/converter.py
CHANGED
|
@@ -21,21 +21,26 @@ from urllib.parse import ParseResult, quote_plus, urlparse
|
|
|
21
21
|
|
|
22
22
|
import lxml.etree as ET
|
|
23
23
|
from strong_typing.core import JsonType
|
|
24
|
+
from strong_typing.exception import JsonTypeError
|
|
24
25
|
|
|
25
26
|
from . import drawio, mermaid
|
|
26
27
|
from .collection import ConfluencePageCollection
|
|
27
28
|
from .csf import AC_ATTR, AC_ELEM, HTML, RI_ATTR, RI_ELEM, ParseError, elements_from_strings, elements_to_string, normalize_inline
|
|
28
29
|
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
30
|
+
from .emoticon import emoji_to_emoticon
|
|
31
|
+
from .environment import PageError
|
|
29
32
|
from .extra import override, path_relative_to
|
|
30
33
|
from .latex import get_png_dimensions, remove_png_chunks, render_latex
|
|
31
34
|
from .markdown import markdown_to_html
|
|
35
|
+
from .mermaid import MermaidConfigProperties
|
|
32
36
|
from .metadata import ConfluenceSiteMetadata
|
|
33
|
-
from .
|
|
34
|
-
from .scanner import ScannedDocument, Scanner
|
|
37
|
+
from .scanner import MermaidScanner, ScannedDocument, Scanner
|
|
35
38
|
from .toc import TableOfContentsBuilder
|
|
36
39
|
from .uri import is_absolute_url, to_uuid_urn
|
|
37
40
|
from .xml import element_to_text
|
|
38
41
|
|
|
42
|
+
ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
|
|
43
|
+
|
|
39
44
|
|
|
40
45
|
def get_volatile_attributes() -> list[str]:
|
|
41
46
|
"Returns a list of volatile attributes that frequently change as a Confluence storage format XHTML document is updated."
|
|
@@ -177,7 +182,7 @@ _LANGUAGES = {
|
|
|
177
182
|
|
|
178
183
|
|
|
179
184
|
class NodeVisitor(ABC):
|
|
180
|
-
def visit(self, node:
|
|
185
|
+
def visit(self, node: ElementType) -> None:
|
|
181
186
|
"Recursively visits all descendants of this node."
|
|
182
187
|
|
|
183
188
|
if len(node) < 1:
|
|
@@ -197,7 +202,7 @@ class NodeVisitor(ABC):
|
|
|
197
202
|
self.visit(source)
|
|
198
203
|
|
|
199
204
|
@abstractmethod
|
|
200
|
-
def transform(self, child:
|
|
205
|
+
def transform(self, child: ElementType) -> Optional[ElementType]: ...
|
|
201
206
|
|
|
202
207
|
|
|
203
208
|
def title_to_identifier(title: str) -> str:
|
|
@@ -209,7 +214,7 @@ def title_to_identifier(title: str) -> str:
|
|
|
209
214
|
return s
|
|
210
215
|
|
|
211
216
|
|
|
212
|
-
def element_text_starts_with_any(node:
|
|
217
|
+
def element_text_starts_with_any(node: ElementType, prefixes: list[str]) -> bool:
|
|
213
218
|
"True if the text contained in an element starts with any of the specified prefix strings."
|
|
214
219
|
|
|
215
220
|
if node.text is None:
|
|
@@ -217,7 +222,7 @@ def element_text_starts_with_any(node: ET._Element, prefixes: list[str]) -> bool
|
|
|
217
222
|
return starts_with_any(node.text, prefixes)
|
|
218
223
|
|
|
219
224
|
|
|
220
|
-
def is_placeholder_for(node:
|
|
225
|
+
def is_placeholder_for(node: ElementType, name: str) -> bool:
|
|
221
226
|
"""
|
|
222
227
|
Identifies a Confluence widget placeholder, e.g. `[[_TOC_]]` or `[[_LISTING_]]`.
|
|
223
228
|
|
|
@@ -244,6 +249,15 @@ class FormattingContext(enum.Enum):
|
|
|
244
249
|
INLINE = "inline"
|
|
245
250
|
|
|
246
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
|
+
|
|
247
261
|
@dataclass
|
|
248
262
|
class ImageAttributes:
|
|
249
263
|
"""
|
|
@@ -255,6 +269,7 @@ class ImageAttributes:
|
|
|
255
269
|
:param alt: Alternate text.
|
|
256
270
|
:param title: Title text (a.k.a. image tooltip).
|
|
257
271
|
:param caption: Caption text (shown below figure).
|
|
272
|
+
:param alignment: Alignment for block-level images.
|
|
258
273
|
"""
|
|
259
274
|
|
|
260
275
|
context: FormattingContext
|
|
@@ -263,6 +278,7 @@ class ImageAttributes:
|
|
|
263
278
|
alt: Optional[str]
|
|
264
279
|
title: Optional[str]
|
|
265
280
|
caption: Optional[str]
|
|
281
|
+
alignment: ImageAlignment = ImageAlignment.CENTER
|
|
266
282
|
|
|
267
283
|
def __post_init__(self) -> None:
|
|
268
284
|
if self.caption is None and self.context is FormattingContext.BLOCK:
|
|
@@ -271,8 +287,16 @@ class ImageAttributes:
|
|
|
271
287
|
def as_dict(self) -> dict[str, str]:
|
|
272
288
|
attributes: dict[str, str] = {}
|
|
273
289
|
if self.context is FormattingContext.BLOCK:
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
|
|
276
300
|
if self.width is not None:
|
|
277
301
|
attributes[AC_ATTR("original-width")] = str(self.width)
|
|
278
302
|
if self.height is not None:
|
|
@@ -308,8 +332,12 @@ class ImageAttributes:
|
|
|
308
332
|
raise NotImplementedError("match not exhaustive for enumeration")
|
|
309
333
|
|
|
310
334
|
|
|
311
|
-
ImageAttributes.EMPTY_BLOCK = ImageAttributes(
|
|
312
|
-
|
|
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
|
+
)
|
|
313
341
|
|
|
314
342
|
|
|
315
343
|
@dataclass
|
|
@@ -327,6 +355,8 @@ class ConfluenceConverterOptions:
|
|
|
327
355
|
:param render_latex: Whether to pre-render LaTeX formulas into PNG/SVG images.
|
|
328
356
|
:param diagram_output_format: Target image format for diagrams.
|
|
329
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.
|
|
330
360
|
"""
|
|
331
361
|
|
|
332
362
|
ignore_invalid_url: bool = False
|
|
@@ -337,6 +367,8 @@ class ConfluenceConverterOptions:
|
|
|
337
367
|
render_latex: bool = False
|
|
338
368
|
diagram_output_format: Literal["png", "svg"] = "png"
|
|
339
369
|
webui_links: bool = False
|
|
370
|
+
alignment: Literal["center", "left", "right"] = "center"
|
|
371
|
+
use_panel: bool = False
|
|
340
372
|
|
|
341
373
|
|
|
342
374
|
@dataclass
|
|
@@ -351,6 +383,43 @@ class EmbeddedFileData:
|
|
|
351
383
|
description: Optional[str] = None
|
|
352
384
|
|
|
353
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
|
+
|
|
354
423
|
class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
355
424
|
"Transforms a plain HTML tree into Confluence Storage Format."
|
|
356
425
|
|
|
@@ -389,7 +458,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
389
458
|
self.site_metadata = site_metadata
|
|
390
459
|
self.page_metadata = page_metadata
|
|
391
460
|
|
|
392
|
-
def _transform_heading(self, heading:
|
|
461
|
+
def _transform_heading(self, heading: ElementType) -> None:
|
|
393
462
|
"""
|
|
394
463
|
Adds anchors to headings in the same document (if *heading anchors* is enabled).
|
|
395
464
|
|
|
@@ -425,15 +494,19 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
425
494
|
anchor.tail = heading.text
|
|
426
495
|
heading.text = None
|
|
427
496
|
|
|
428
|
-
def
|
|
497
|
+
def _anchor_warn_or_raise(self, anchor: ElementType, msg: str) -> None:
|
|
429
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."
|
|
430
499
|
|
|
431
500
|
if self.options.ignore_invalid_url:
|
|
432
501
|
LOGGER.warning(msg)
|
|
502
|
+
if anchor.text:
|
|
503
|
+
anchor.text = "❌ " + anchor.text
|
|
504
|
+
elif len(anchor) > 0:
|
|
505
|
+
anchor.text = "❌ "
|
|
433
506
|
else:
|
|
434
507
|
raise DocumentError(msg)
|
|
435
508
|
|
|
436
|
-
def _transform_link(self, anchor:
|
|
509
|
+
def _transform_link(self, anchor: ElementType) -> Optional[ElementType]:
|
|
437
510
|
"""
|
|
438
511
|
Transforms links (HTML anchor `<a>`).
|
|
439
512
|
|
|
@@ -478,7 +551,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
478
551
|
|
|
479
552
|
# look up the absolute path in the page metadata dictionary to discover the relative path within Confluence that should be used
|
|
480
553
|
if not is_directory_within(absolute_path, self.root_dir):
|
|
481
|
-
self.
|
|
554
|
+
self._anchor_warn_or_raise(anchor, f"relative URL {url} points to outside root path: {self.root_dir}")
|
|
482
555
|
return None
|
|
483
556
|
|
|
484
557
|
if absolute_path.suffix == ".md":
|
|
@@ -486,14 +559,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
486
559
|
else:
|
|
487
560
|
return self._transform_attachment_link(anchor, absolute_path)
|
|
488
561
|
|
|
489
|
-
def _transform_page_link(self, anchor:
|
|
562
|
+
def _transform_page_link(self, anchor: ElementType, relative_url: ParseResult, absolute_path: Path) -> Optional[ElementType]:
|
|
490
563
|
"""
|
|
491
564
|
Transforms links to other Markdown documents (Confluence pages).
|
|
492
565
|
"""
|
|
493
566
|
|
|
494
567
|
link_metadata = self.page_metadata.get(absolute_path)
|
|
495
568
|
if link_metadata is None:
|
|
496
|
-
self.
|
|
569
|
+
self._anchor_warn_or_raise(anchor, f"unable to find matching page for URL: {relative_url.geturl()}")
|
|
497
570
|
return None
|
|
498
571
|
|
|
499
572
|
relative_path = os.path.relpath(absolute_path, self.base_dir)
|
|
@@ -523,13 +596,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
523
596
|
anchor.set("href", transformed_url.geturl())
|
|
524
597
|
return None
|
|
525
598
|
|
|
526
|
-
def _transform_attachment_link(self, anchor:
|
|
599
|
+
def _transform_attachment_link(self, anchor: ElementType, absolute_path: Path) -> Optional[ElementType]:
|
|
527
600
|
"""
|
|
528
601
|
Transforms links to document binaries such as PDF, DOCX or XLSX.
|
|
529
602
|
"""
|
|
530
603
|
|
|
531
604
|
if not absolute_path.exists():
|
|
532
|
-
self.
|
|
605
|
+
self._anchor_warn_or_raise(anchor, f"relative URL points to non-existing file: {absolute_path}")
|
|
533
606
|
return None
|
|
534
607
|
|
|
535
608
|
file_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
|
|
@@ -545,7 +618,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
545
618
|
)
|
|
546
619
|
return link_wrapper
|
|
547
620
|
|
|
548
|
-
def _transform_status(self, color: str, caption: str) ->
|
|
621
|
+
def _transform_status(self, color: str, caption: str) -> ElementType:
|
|
549
622
|
macro_id = str(uuid.uuid4())
|
|
550
623
|
attributes = {
|
|
551
624
|
AC_ATTR("name"): "status",
|
|
@@ -578,7 +651,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
578
651
|
),
|
|
579
652
|
)
|
|
580
653
|
|
|
581
|
-
def _transform_image(self, context: FormattingContext, image:
|
|
654
|
+
def _transform_image(self, context: FormattingContext, image: ElementType) -> ElementType:
|
|
582
655
|
"Inserts an attached or external image."
|
|
583
656
|
|
|
584
657
|
src = image.get("src")
|
|
@@ -594,7 +667,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
594
667
|
height = image.get("height")
|
|
595
668
|
pixel_width = int(width) if width is not None and width.isdecimal() else None
|
|
596
669
|
pixel_height = int(height) if height is not None and height.isdecimal() else None
|
|
597
|
-
attrs = ImageAttributes(
|
|
670
|
+
attrs = ImageAttributes(
|
|
671
|
+
context, width=pixel_width, height=pixel_height, alt=alt, title=title, caption=None, alignment=ImageAlignment(self.options.alignment)
|
|
672
|
+
)
|
|
598
673
|
|
|
599
674
|
if is_absolute_url(src):
|
|
600
675
|
return self._transform_external_image(src, attrs)
|
|
@@ -603,7 +678,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
603
678
|
|
|
604
679
|
absolute_path = self._verify_image_path(path)
|
|
605
680
|
if absolute_path is None:
|
|
606
|
-
return self._create_missing(path, attrs
|
|
681
|
+
return self._create_missing(path, attrs)
|
|
607
682
|
|
|
608
683
|
if absolute_path.name.endswith(".drawio.png") or absolute_path.name.endswith(".drawio.svg"):
|
|
609
684
|
return self._transform_drawio_image(absolute_path, attrs)
|
|
@@ -614,10 +689,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
614
689
|
else:
|
|
615
690
|
return self._transform_attached_image(absolute_path, attrs)
|
|
616
691
|
|
|
617
|
-
def _transform_external_image(self, url: str, attrs: ImageAttributes) ->
|
|
692
|
+
def _transform_external_image(self, url: str, attrs: ImageAttributes) -> ElementType:
|
|
618
693
|
"Emits Confluence Storage Format XHTML for an external image."
|
|
619
694
|
|
|
620
|
-
elements: list[
|
|
695
|
+
elements: list[ElementType] = []
|
|
621
696
|
elements.append(
|
|
622
697
|
RI_ELEM(
|
|
623
698
|
"url",
|
|
@@ -630,6 +705,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
630
705
|
|
|
631
706
|
return AC_ELEM("image", attrs.as_dict(), *elements)
|
|
632
707
|
|
|
708
|
+
def _warn_or_raise(self, msg: str) -> None:
|
|
709
|
+
"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."
|
|
710
|
+
|
|
711
|
+
if self.options.ignore_invalid_url:
|
|
712
|
+
LOGGER.warning(msg)
|
|
713
|
+
else:
|
|
714
|
+
raise DocumentError(msg)
|
|
715
|
+
|
|
633
716
|
def _verify_image_path(self, path: Path) -> Optional[Path]:
|
|
634
717
|
"Checks whether an image path is safe to use."
|
|
635
718
|
|
|
@@ -646,7 +729,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
646
729
|
|
|
647
730
|
return absolute_path
|
|
648
731
|
|
|
649
|
-
def _transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) ->
|
|
732
|
+
def _transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
650
733
|
"Emits Confluence Storage Format XHTML for an attached raster or vector image."
|
|
651
734
|
|
|
652
735
|
if self.options.prefer_raster and absolute_path.suffix == ".svg":
|
|
@@ -659,7 +742,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
659
742
|
image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
|
|
660
743
|
return self._create_attached_image(image_name, attrs)
|
|
661
744
|
|
|
662
|
-
def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) ->
|
|
745
|
+
def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
663
746
|
"Emits Confluence Storage Format XHTML for a draw.io diagram."
|
|
664
747
|
|
|
665
748
|
if not absolute_path.name.endswith(".drawio.xml") and not absolute_path.name.endswith(".drawio"):
|
|
@@ -676,7 +759,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
676
759
|
image_filename = attachment_name(relative_path)
|
|
677
760
|
return self._create_drawio(image_filename, attrs)
|
|
678
761
|
|
|
679
|
-
def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) ->
|
|
762
|
+
def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
680
763
|
"Emits Confluence Storage Format XHTML for a draw.io diagram embedded in a PNG or SVG image."
|
|
681
764
|
|
|
682
765
|
if not absolute_path.name.endswith(".drawio.png") and not absolute_path.name.endswith(".drawio.svg"):
|
|
@@ -692,10 +775,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
692
775
|
|
|
693
776
|
return self._create_drawio(image_filename, attrs)
|
|
694
777
|
|
|
695
|
-
def _create_attached_image(self, image_name: str, attrs: ImageAttributes) ->
|
|
778
|
+
def _create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ElementType:
|
|
696
779
|
"An image embedded into the page, linking to an attachment."
|
|
697
780
|
|
|
698
|
-
elements: list[
|
|
781
|
+
elements: list[ElementType] = []
|
|
699
782
|
elements.append(
|
|
700
783
|
RI_ELEM(
|
|
701
784
|
"attachment",
|
|
@@ -708,10 +791,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
708
791
|
|
|
709
792
|
return AC_ELEM("image", attrs.as_dict(), *elements)
|
|
710
793
|
|
|
711
|
-
def _create_drawio(self, filename: str, attrs: ImageAttributes) ->
|
|
794
|
+
def _create_drawio(self, filename: str, attrs: ImageAttributes) -> ElementType:
|
|
712
795
|
"A draw.io diagram embedded into the page, linking to an attachment."
|
|
713
796
|
|
|
714
|
-
parameters: list[
|
|
797
|
+
parameters: list[ElementType] = [
|
|
715
798
|
AC_ELEM(
|
|
716
799
|
"parameter",
|
|
717
800
|
{AC_ATTR("name"): "diagramName"},
|
|
@@ -749,32 +832,35 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
749
832
|
*parameters,
|
|
750
833
|
)
|
|
751
834
|
|
|
752
|
-
def _create_missing(self, path: Path,
|
|
835
|
+
def _create_missing(self, path: Path, attrs: ImageAttributes) -> ElementType:
|
|
753
836
|
"A warning panel for a missing image."
|
|
754
837
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
838
|
+
if attrs.context is FormattingContext.BLOCK:
|
|
839
|
+
message = HTML.p("❌ Missing image: ", HTML.code(path.as_posix()))
|
|
840
|
+
if attrs.caption is not None:
|
|
841
|
+
content = [
|
|
842
|
+
AC_ELEM(
|
|
843
|
+
"parameter",
|
|
844
|
+
{AC_ATTR("name"): "title"},
|
|
845
|
+
attrs.caption,
|
|
846
|
+
),
|
|
847
|
+
AC_ELEM("rich-text-body", {}, message),
|
|
848
|
+
]
|
|
849
|
+
else:
|
|
850
|
+
content = [AC_ELEM("rich-text-body", {}, message)]
|
|
767
851
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
852
|
+
return AC_ELEM(
|
|
853
|
+
"structured-macro",
|
|
854
|
+
{
|
|
855
|
+
AC_ATTR("name"): "warning",
|
|
856
|
+
AC_ATTR("schema-version"): "1",
|
|
857
|
+
},
|
|
858
|
+
*content,
|
|
859
|
+
)
|
|
860
|
+
else:
|
|
861
|
+
return HTML.span({"style": "color: rgb(255,86,48);"}, "❌ ", HTML.code(path.as_posix()))
|
|
776
862
|
|
|
777
|
-
def _transform_code_block(self, code:
|
|
863
|
+
def _transform_code_block(self, code: ElementType) -> ElementType:
|
|
778
864
|
"Transforms a code block."
|
|
779
865
|
|
|
780
866
|
if language_class := code.get("class"):
|
|
@@ -811,7 +897,16 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
811
897
|
AC_ELEM("plain-text-body", ET.CDATA(content)),
|
|
812
898
|
)
|
|
813
899
|
|
|
814
|
-
def
|
|
900
|
+
def _extract_mermaid_config(self, content: str) -> Optional[MermaidConfigProperties]:
|
|
901
|
+
"""Extract scale from Mermaid YAML front matter configuration."""
|
|
902
|
+
try:
|
|
903
|
+
properties = MermaidScanner().read(content)
|
|
904
|
+
return properties.config
|
|
905
|
+
except JsonTypeError as ex:
|
|
906
|
+
LOGGER.warning("Failed to extract Mermaid properties: %s", ex)
|
|
907
|
+
return None
|
|
908
|
+
|
|
909
|
+
def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
815
910
|
"Emits Confluence Storage Format XHTML for a Mermaid diagram read from an external file."
|
|
816
911
|
|
|
817
912
|
if not absolute_path.name.endswith(".mmd") and not absolute_path.name.endswith(".mermaid"):
|
|
@@ -821,7 +916,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
821
916
|
if self.options.render_mermaid:
|
|
822
917
|
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
823
918
|
content = f.read()
|
|
824
|
-
|
|
919
|
+
config = self._extract_mermaid_config(content)
|
|
920
|
+
image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
|
|
825
921
|
image_filename = attachment_name(relative_path.with_suffix(f".{self.options.diagram_output_format}"))
|
|
826
922
|
self.embedded_files[image_filename] = EmbeddedFileData(image_data, attrs.alt)
|
|
827
923
|
return self._create_attached_image(image_filename, attrs)
|
|
@@ -830,11 +926,12 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
830
926
|
mermaid_filename = attachment_name(relative_path)
|
|
831
927
|
return self._create_mermaid_embed(mermaid_filename)
|
|
832
928
|
|
|
833
|
-
def _transform_fenced_mermaid(self, content: str) ->
|
|
929
|
+
def _transform_fenced_mermaid(self, content: str) -> ElementType:
|
|
834
930
|
"Emits Confluence Storage Format XHTML for a Mermaid diagram defined in a fenced code block."
|
|
835
931
|
|
|
836
932
|
if self.options.render_mermaid:
|
|
837
|
-
|
|
933
|
+
config = self._extract_mermaid_config(content)
|
|
934
|
+
image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
|
|
838
935
|
image_hash = hashlib.md5(image_data).hexdigest()
|
|
839
936
|
image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
|
|
840
937
|
self.embedded_files[image_filename] = EmbeddedFileData(image_data)
|
|
@@ -846,7 +943,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
846
943
|
self.embedded_files[mermaid_filename] = EmbeddedFileData(mermaid_data)
|
|
847
944
|
return self._create_mermaid_embed(mermaid_filename)
|
|
848
945
|
|
|
849
|
-
def _create_mermaid_embed(self, filename: str) ->
|
|
946
|
+
def _create_mermaid_embed(self, filename: str) -> ElementType:
|
|
850
947
|
"A Mermaid diagram, linking to an attachment that captures the Mermaid source."
|
|
851
948
|
|
|
852
949
|
local_id = str(uuid.uuid4())
|
|
@@ -878,7 +975,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
878
975
|
AC_ELEM("parameter", {AC_ATTR("name"): "revision"}, "1"),
|
|
879
976
|
)
|
|
880
977
|
|
|
881
|
-
def _transform_toc(self, code:
|
|
978
|
+
def _transform_toc(self, code: ElementType) -> ElementType:
|
|
882
979
|
"Creates a table of contents, constructed from headings in the document."
|
|
883
980
|
|
|
884
981
|
return AC_ELEM(
|
|
@@ -892,7 +989,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
892
989
|
AC_ELEM("parameter", {AC_ATTR("name"): "style"}, "default"),
|
|
893
990
|
)
|
|
894
991
|
|
|
895
|
-
def _transform_listing(self, code:
|
|
992
|
+
def _transform_listing(self, code: ElementType) -> ElementType:
|
|
896
993
|
"Creates a list of child pages."
|
|
897
994
|
|
|
898
995
|
return AC_ELEM(
|
|
@@ -905,7 +1002,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
905
1002
|
AC_ELEM("parameter", {AC_ATTR("name"): "allChildren"}, "true"),
|
|
906
1003
|
)
|
|
907
1004
|
|
|
908
|
-
def _transform_admonition(self, elem:
|
|
1005
|
+
def _transform_admonition(self, elem: ElementType) -> ElementType:
|
|
909
1006
|
"""
|
|
910
1007
|
Creates an info, tip, note or warning panel from a Markdown admonition.
|
|
911
1008
|
|
|
@@ -918,45 +1015,51 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
918
1015
|
|
|
919
1016
|
# <div class="admonition note">
|
|
920
1017
|
class_list = elem.get("class", "").split(" ")
|
|
921
|
-
|
|
922
|
-
if
|
|
923
|
-
|
|
924
|
-
elif
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
class_name = "note"
|
|
928
|
-
elif "warning" in class_list:
|
|
929
|
-
class_name = "warning"
|
|
930
|
-
|
|
931
|
-
if class_name is None:
|
|
932
|
-
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]
|
|
933
1024
|
|
|
934
1025
|
for e in elem:
|
|
935
1026
|
self.visit(e)
|
|
936
1027
|
|
|
937
1028
|
# <p class="admonition-title">Note</p>
|
|
938
1029
|
if "admonition-title" in elem[0].get("class", "").split(" "):
|
|
939
|
-
content = [
|
|
940
|
-
AC_ELEM(
|
|
941
|
-
"parameter",
|
|
942
|
-
{AC_ATTR("name"): "title"},
|
|
943
|
-
elem[0].text or "",
|
|
944
|
-
),
|
|
945
|
-
AC_ELEM("rich-text-body", {}, *list(elem[1:])),
|
|
946
|
-
]
|
|
1030
|
+
content = [HTML.p(HTML.strong(elem[0].text or "")), *list(elem[1:])]
|
|
947
1031
|
else:
|
|
948
|
-
content =
|
|
1032
|
+
content = list(elem)
|
|
949
1033
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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}")
|
|
1052
|
+
|
|
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
|
+
)
|
|
958
1061
|
|
|
959
|
-
def _transform_github_alert(self, blockquote:
|
|
1062
|
+
def _transform_github_alert(self, blockquote: ElementType) -> ElementType:
|
|
960
1063
|
"""
|
|
961
1064
|
Creates a GitHub-style panel, normally triggered with a block-quote starting with a capitalized string such as `[!TIP]`.
|
|
962
1065
|
"""
|
|
@@ -968,30 +1071,29 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
968
1071
|
if content.text is None:
|
|
969
1072
|
raise DocumentError("empty content")
|
|
970
1073
|
|
|
971
|
-
class_name: Optional[str] = None
|
|
972
|
-
skip = 0
|
|
973
|
-
|
|
974
1074
|
pattern = re.compile(r"^\[!([A-Z]+)\]\s*")
|
|
975
1075
|
match = pattern.match(content.text)
|
|
976
|
-
if match:
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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:
|
|
990
1092
|
raise DocumentError(f"unsupported GitHub alert: {alert}")
|
|
991
1093
|
|
|
992
|
-
|
|
1094
|
+
return self._transform_alert(blockquote, class_name)
|
|
993
1095
|
|
|
994
|
-
def _transform_gitlab_alert(self, blockquote:
|
|
1096
|
+
def _transform_gitlab_alert(self, blockquote: ElementType) -> ElementType:
|
|
995
1097
|
"""
|
|
996
1098
|
Creates a classic GitLab-style panel.
|
|
997
1099
|
|
|
@@ -1006,58 +1108,91 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1006
1108
|
if content.text is None:
|
|
1007
1109
|
raise DocumentError("empty content")
|
|
1008
1110
|
|
|
1009
|
-
class_name: Optional[str] = None
|
|
1010
|
-
skip = 0
|
|
1011
|
-
|
|
1012
1111
|
pattern = re.compile(r"^(FLAG|NOTE|WARNING|DISCLAIMER):\s*")
|
|
1013
1112
|
match = pattern.match(content.text)
|
|
1014
|
-
if match:
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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:
|
|
1026
1129
|
raise DocumentError(f"unsupported GitLab alert: {alert}")
|
|
1027
1130
|
|
|
1028
|
-
|
|
1131
|
+
return self._transform_alert(blockquote, class_name)
|
|
1029
1132
|
|
|
1030
|
-
def _transform_alert(self, blockquote:
|
|
1133
|
+
def _transform_alert(self, blockquote: ElementType, class_name: str) -> ElementType:
|
|
1031
1134
|
"""
|
|
1032
|
-
Creates an info
|
|
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`.
|
|
1141
|
+
|
|
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
|
+
```
|
|
1033
1154
|
|
|
1034
|
-
|
|
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`.
|
|
1035
1159
|
|
|
1036
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
|
|
1037
1161
|
:see: https://docs.gitlab.com/ee/development/documentation/styleguide/#alert-boxes
|
|
1038
1162
|
"""
|
|
1039
1163
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
+
)
|
|
1043
1172
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1173
|
+
def _transform_panel(self, content: list[ElementType], class_name: str) -> ElementType:
|
|
1174
|
+
"Transforms a blockquote into a themed panel."
|
|
1046
1175
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1176
|
+
panel = ConfluencePanel.from_class.get(class_name)
|
|
1177
|
+
if panel is None:
|
|
1178
|
+
raise DocumentError(f"unsupported panel class: {class_name}")
|
|
1049
1179
|
|
|
1050
|
-
|
|
1180
|
+
macro_id = str(uuid.uuid4())
|
|
1051
1181
|
return AC_ELEM(
|
|
1052
1182
|
"structured-macro",
|
|
1053
1183
|
{
|
|
1054
|
-
AC_ATTR("name"):
|
|
1184
|
+
AC_ATTR("name"): "panel",
|
|
1055
1185
|
AC_ATTR("schema-version"): "1",
|
|
1186
|
+
AC_ATTR("macro-id"): macro_id,
|
|
1056
1187
|
},
|
|
1057
|
-
AC_ELEM("
|
|
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),
|
|
1058
1193
|
)
|
|
1059
1194
|
|
|
1060
|
-
def
|
|
1195
|
+
def _transform_collapsed(self, details: ElementType) -> ElementType:
|
|
1061
1196
|
"""
|
|
1062
1197
|
Creates a collapsed section.
|
|
1063
1198
|
|
|
@@ -1106,7 +1241,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1106
1241
|
AC_ELEM("rich-text-body", {}, *list(details)),
|
|
1107
1242
|
)
|
|
1108
1243
|
|
|
1109
|
-
def _transform_emoji(self, elem:
|
|
1244
|
+
def _transform_emoji(self, elem: ElementType) -> ElementType:
|
|
1110
1245
|
"""
|
|
1111
1246
|
Inserts an inline emoji character.
|
|
1112
1247
|
"""
|
|
@@ -1115,18 +1250,22 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1115
1250
|
unicode = elem.get("data-unicode", None)
|
|
1116
1251
|
alt = elem.text or ""
|
|
1117
1252
|
|
|
1253
|
+
# emoji with a matching emoticon:
|
|
1118
1254
|
# <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="😉"/>
|
|
1255
|
+
#
|
|
1256
|
+
# emoji without a corresponding emoticon:
|
|
1257
|
+
# <ac:emoticon ac:name="blue-star" ac:emoji-shortname=":shield:" ac:emoji-id="1f6e1" ac:emoji-fallback="🛡"/>
|
|
1119
1258
|
return AC_ELEM(
|
|
1120
1259
|
"emoticon",
|
|
1121
1260
|
{
|
|
1122
|
-
AC_ATTR("name"): shortname,
|
|
1261
|
+
AC_ATTR("name"): emoji_to_emoticon(shortname),
|
|
1123
1262
|
AC_ATTR("emoji-shortname"): f":{shortname}:",
|
|
1124
1263
|
AC_ATTR("emoji-id"): unicode,
|
|
1125
1264
|
AC_ATTR("emoji-fallback"): alt,
|
|
1126
1265
|
},
|
|
1127
1266
|
)
|
|
1128
1267
|
|
|
1129
|
-
def _transform_mark(self, mark:
|
|
1268
|
+
def _transform_mark(self, mark: ElementType) -> ElementType:
|
|
1130
1269
|
"""
|
|
1131
1270
|
Adds inline highlighting to text.
|
|
1132
1271
|
"""
|
|
@@ -1141,7 +1280,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1141
1280
|
span.text = mark.text
|
|
1142
1281
|
return span
|
|
1143
1282
|
|
|
1144
|
-
def _transform_latex(self, elem:
|
|
1283
|
+
def _transform_latex(self, elem: ElementType, context: FormattingContext) -> ElementType:
|
|
1145
1284
|
"""
|
|
1146
1285
|
Creates an image rendering of a LaTeX formula with Matplotlib.
|
|
1147
1286
|
"""
|
|
@@ -1154,7 +1293,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1154
1293
|
if self.options.diagram_output_format == "png":
|
|
1155
1294
|
width, height = get_png_dimensions(data=image_data)
|
|
1156
1295
|
image_data = remove_png_chunks(["pHYs"], source_data=image_data)
|
|
1157
|
-
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))
|
|
1158
1297
|
else:
|
|
1159
1298
|
attrs = ImageAttributes.empty(context)
|
|
1160
1299
|
|
|
@@ -1164,7 +1303,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1164
1303
|
image = self._create_attached_image(image_filename, attrs)
|
|
1165
1304
|
return image
|
|
1166
1305
|
|
|
1167
|
-
def _transform_inline_math(self, elem:
|
|
1306
|
+
def _transform_inline_math(self, elem: ElementType) -> ElementType:
|
|
1168
1307
|
"""
|
|
1169
1308
|
Creates an inline LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
|
|
1170
1309
|
|
|
@@ -1195,11 +1334,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1195
1334
|
{AC_ATTR("name"): "body"},
|
|
1196
1335
|
content,
|
|
1197
1336
|
),
|
|
1198
|
-
AC_ELEM("parameter", {AC_ATTR("name"): "align"},
|
|
1337
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
|
|
1199
1338
|
)
|
|
1200
1339
|
return macro
|
|
1201
1340
|
|
|
1202
|
-
def _transform_block_math(self, elem:
|
|
1341
|
+
def _transform_block_math(self, elem: ElementType) -> ElementType:
|
|
1203
1342
|
"""
|
|
1204
1343
|
Creates a block-level LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
|
|
1205
1344
|
|
|
@@ -1232,15 +1371,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1232
1371
|
{AC_ATTR("name"): "body"},
|
|
1233
1372
|
content,
|
|
1234
1373
|
),
|
|
1235
|
-
AC_ELEM("parameter", {AC_ATTR("name"): "align"},
|
|
1374
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
|
|
1236
1375
|
)
|
|
1237
1376
|
|
|
1238
|
-
def _transform_footnote_ref(self, elem:
|
|
1377
|
+
def _transform_footnote_ref(self, elem: ElementType) -> None:
|
|
1239
1378
|
"""
|
|
1240
1379
|
Transforms a footnote reference.
|
|
1241
1380
|
|
|
1242
1381
|
```
|
|
1243
|
-
<sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">
|
|
1382
|
+
<sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">REF</a></sup>
|
|
1244
1383
|
```
|
|
1245
1384
|
"""
|
|
1246
1385
|
|
|
@@ -1292,7 +1431,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1292
1431
|
elem.append(ref_anchor)
|
|
1293
1432
|
elem.append(def_link)
|
|
1294
1433
|
|
|
1295
|
-
def _transform_footnote_def(self, elem:
|
|
1434
|
+
def _transform_footnote_def(self, elem: ElementType) -> None:
|
|
1296
1435
|
"""
|
|
1297
1436
|
Transforms the footnote definition block.
|
|
1298
1437
|
|
|
@@ -1366,7 +1505,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1366
1505
|
paragraph.text = None
|
|
1367
1506
|
paragraph.append(ref_link)
|
|
1368
1507
|
|
|
1369
|
-
def _transform_tasklist(self, elem:
|
|
1508
|
+
def _transform_tasklist(self, elem: ElementType) -> ElementType:
|
|
1370
1509
|
"""
|
|
1371
1510
|
Transforms a list of tasks into an action widget.
|
|
1372
1511
|
|
|
@@ -1382,7 +1521,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1382
1521
|
if not element_text_starts_with_any(item, ["[ ]", "[x]", "[X]"]):
|
|
1383
1522
|
raise DocumentError("expected: each `<li>` in a task list starting with [ ] or [x]")
|
|
1384
1523
|
|
|
1385
|
-
tasks: list[
|
|
1524
|
+
tasks: list[ElementType] = []
|
|
1386
1525
|
for index, item in enumerate(elem, start=1):
|
|
1387
1526
|
if item.text is None:
|
|
1388
1527
|
raise NotImplementedError("pre-condition check not exhaustive")
|
|
@@ -1411,7 +1550,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1411
1550
|
return AC_ELEM("task-list", {}, *tasks)
|
|
1412
1551
|
|
|
1413
1552
|
@override
|
|
1414
|
-
def transform(self, child:
|
|
1553
|
+
def transform(self, child: ElementType) -> Optional[ElementType]:
|
|
1415
1554
|
"""
|
|
1416
1555
|
Transforms an HTML element tree obtained from a Markdown document into a Confluence Storage Format element tree.
|
|
1417
1556
|
"""
|
|
@@ -1498,7 +1637,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1498
1637
|
# ...
|
|
1499
1638
|
# </details>
|
|
1500
1639
|
elif child.tag == "details" and len(child) > 1 and child[0].tag == "summary":
|
|
1501
|
-
return self.
|
|
1640
|
+
return self._transform_collapsed(child)
|
|
1502
1641
|
|
|
1503
1642
|
# <ol>...</ol>
|
|
1504
1643
|
elif child.tag == "ol":
|
|
@@ -1526,6 +1665,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1526
1665
|
|
|
1527
1666
|
# <table>...</table>
|
|
1528
1667
|
elif child.tag == "table":
|
|
1668
|
+
for td in child.iterdescendants("td", "th"):
|
|
1669
|
+
normalize_inline(td)
|
|
1529
1670
|
child.set("data-layout", "default")
|
|
1530
1671
|
return None
|
|
1531
1672
|
|
|
@@ -1602,7 +1743,7 @@ class ConfluenceDocument:
|
|
|
1602
1743
|
embedded_files: dict[str, EmbeddedFileData]
|
|
1603
1744
|
|
|
1604
1745
|
options: ConfluenceDocumentOptions
|
|
1605
|
-
root:
|
|
1746
|
+
root: ElementType
|
|
1606
1747
|
|
|
1607
1748
|
@classmethod
|
|
1608
1749
|
def create(
|
|
@@ -1648,10 +1789,10 @@ class ConfluenceDocument:
|
|
|
1648
1789
|
lines.append(f"[STATUS-{color.upper()}]: {data_uri}")
|
|
1649
1790
|
lines.append(document.text)
|
|
1650
1791
|
|
|
1651
|
-
# convert to HTML
|
|
1792
|
+
# parse Markdown document and convert to HTML
|
|
1652
1793
|
html = markdown_to_html("\n".join(lines))
|
|
1653
1794
|
|
|
1654
|
-
#
|
|
1795
|
+
# modify HTML as necessary
|
|
1655
1796
|
if self.options.generated_by is not None:
|
|
1656
1797
|
generated_by = document.generated_by or self.options.generated_by
|
|
1657
1798
|
else:
|
|
@@ -1669,26 +1810,32 @@ class ConfluenceDocument:
|
|
|
1669
1810
|
else:
|
|
1670
1811
|
content = [html]
|
|
1671
1812
|
|
|
1813
|
+
# parse HTML into element tree
|
|
1672
1814
|
try:
|
|
1673
1815
|
self.root = elements_from_strings(content)
|
|
1674
1816
|
except ParseError as ex:
|
|
1675
1817
|
raise ConversionError(path) from ex
|
|
1676
1818
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
root_dir,
|
|
1681
|
-
site_metadata,
|
|
1682
|
-
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)}
|
|
1683
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
|
|
1684
1828
|
try:
|
|
1685
1829
|
converter.visit(self.root)
|
|
1686
1830
|
except DocumentError as ex:
|
|
1687
1831
|
raise ConversionError(path) from ex
|
|
1832
|
+
|
|
1833
|
+
# extract information discovered by converter
|
|
1688
1834
|
self.links = converter.links
|
|
1689
1835
|
self.images = converter.images
|
|
1690
1836
|
self.embedded_files = converter.embedded_files
|
|
1691
1837
|
|
|
1838
|
+
# assign global properties for document
|
|
1692
1839
|
self.title = document.title or converter.toc.get_title()
|
|
1693
1840
|
self.labels = document.tags
|
|
1694
1841
|
self.properties = document.properties
|