markdown-to-confluence 0.4.5__py3-none-any.whl → 0.4.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-to-confluence
3
- Version: 0.4.5
3
+ Version: 0.4.6
4
4
  Summary: Publish Markdown files to Confluence wiki
5
5
  Author-email: Levente Hunyadi <hunyadi@gmail.com>
6
6
  Maintainer-email: Levente Hunyadi <hunyadi@gmail.com>
@@ -23,12 +23,14 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.9
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
+ Requires-Dist: certifi>=2025.8.3; python_version < "3.10"
26
27
  Requires-Dist: json_strong_typing>=0.4
27
28
  Requires-Dist: lxml>=6.0
28
29
  Requires-Dist: markdown>=3.8
29
30
  Requires-Dist: pymdown-extensions>=10.16
30
31
  Requires-Dist: PyYAML>=6.0
31
32
  Requires-Dist: requests>=2.32
33
+ Requires-Dist: truststore>=0.10; python_version >= "3.10"
32
34
  Requires-Dist: typing-extensions>=4.14; python_version < "3.12"
33
35
  Provides-Extra: dev
34
36
  Requires-Dist: markdown_doc>=0.1.4; python_version >= "3.10" and extra == "dev"
@@ -143,7 +145,7 @@ export CONFLUENCE_SPACE_KEY='SPACE'
143
145
 
144
146
  On Windows, these can be set via system properties.
145
147
 
146
- If you use Atlassian scoped API tokens, you should set API URL, substituting `CLOUD_ID` with your own Cloud ID:
148
+ If you use Atlassian scoped API tokens, you may want to set API URL directly, substituting `CLOUD_ID` with your own Cloud ID:
147
149
 
148
150
  ```sh
149
151
  export CONFLUENCE_API_URL='https://api.atlassian.com/ex/confluence/CLOUD_ID/'
@@ -151,20 +153,27 @@ export CONFLUENCE_API_URL='https://api.atlassian.com/ex/confluence/CLOUD_ID/'
151
153
 
152
154
  In this case, *md2conf* can automatically determine `CONFLUENCE_DOMAIN` and `CONFLUENCE_PATH`.
153
155
 
156
+ If you can't find your `CLOUD_ID` but assign both `CONFLUENCE_DOMAIN` and `CONFLUENCE_PATH`, *md2conf* makes a best-effort attempt to determine `CONFLUENCE_API_URL`.
157
+
154
158
  ### Permissions
155
159
 
156
160
  The tool requires appropriate permissions in Confluence in order to invoke endpoints.
157
161
 
158
- Required scopes for scoped API tokens are as follows:
162
+ We recommend the following scopes for scoped API tokens:
159
163
 
164
+ * `read:attachment:confluence`
165
+ * `read:content:confluence`
166
+ * `read:content-details:confluence`
167
+ * `read:label:confluence`
160
168
  * `read:page:confluence`
161
- * `write:page:confluence`
162
169
  * `read:space:confluence`
163
- * `write:space:confluence`
164
- * `read:attachment:confluence`
165
170
  * `write:attachment:confluence`
166
- * `read:label:confluence`
171
+ * `write:content:confluence`
167
172
  * `write:label:confluence`
173
+ * `write:page:confluence`
174
+ * `delete:attachment:confluence`
175
+ * `delete:content:confluence`
176
+ * `delete:page:confluence`
168
177
 
169
178
  If a Confluence username is set, the tool uses HTTP *Basic* authentication to pass the username and the API key to Confluence REST API endpoints. If no username is provided, the tool authenticates with HTTP *Bearer*, and passes the API key as the bearer token.
170
179
 
@@ -331,6 +340,21 @@ The following table shows standard highlight colors (CSS `background-color`) tha
331
340
 
332
341
  If your Markdown lists or tables don't appear in Confluence as expected, verify that the list or table is delimited by a blank line both before and after, as per strict Markdown syntax. While some previewers accept a more lenient syntax (e.g. an itemized list immediately following a paragraph), *md2conf* uses [Python-Markdown](https://python-markdown.github.io/) internally to convert Markdown into XHTML, which expects the Markdown document to adhere to the stricter syntax.
333
342
 
343
+ Likewise, if you have a nested list, make sure that nested items are indented by exactly ***four*** spaces as compared to the parent node:
344
+
345
+ ```markdown
346
+ 1. List item 1
347
+ * Nested item 1
348
+ 1. Item 1
349
+ 2. Item 2
350
+ * Nested item 2
351
+ - Item 3
352
+ - Item 4
353
+ 2. List item 2
354
+ 1. Nested item 3
355
+ 2. Nested item 4
356
+ ```
357
+
334
358
  ### Publishing images
335
359
 
336
360
  Local images referenced in a Markdown file are automatically published to Confluence as attachments to the page.
@@ -348,7 +372,7 @@ Inline formulas can be enclosed with `$` signs, or delimited with `\(` and `\)`,
348
372
 
349
373
  Block formulas can be enclosed with `$$`, or wrapped in code blocks specifying the language `math`:
350
374
 
351
- ```md
375
+ ```markdown
352
376
  $$\int _{a}^{b}f(x)dx=F(b)-F(a)$$
353
377
  ```
354
378
 
@@ -385,7 +409,7 @@ Displaying math formulas in Confluence requires the extension [LaTeX Math for Co
385
409
 
386
410
  Use the pseudo-language `csf` in a Markdown code block to pass content directly to Confluence. The content must be a single XML node that conforms to Confluence Storage Format (typically an `ac:structured-macro`) but is otherwise not validated. The following example shows how to create a panel similar to an *info panel* but with custom background color and emoji. Notice that `ac:rich-text-body` uses XHTML, not Markdown.
387
411
 
388
- ````md
412
+ ````markdown
389
413
  ```csf
390
414
  <ac:structured-macro ac:name="panel" ac:schema-version="1">
391
415
  <ac:parameter ac:name="panelIcon">:slight_smile:</ac:parameter>
@@ -496,7 +520,7 @@ If *md2conf* encounters a Markdown link that points to a file in the directory h
496
520
 
497
521
  *md2conf* implicitly defines some URLs, as if you included the following at the start of the Markdown document for each URL:
498
522
 
499
- ```md
523
+ ```markdown
500
524
  [CUSTOM-URL]: https://example.com/path/to/resource
501
525
  ```
502
526
 
@@ -531,7 +555,7 @@ options:
531
555
  --api-url API_URL Confluence API URL. Required for scoped tokens. Refer to documentation how to obtain one.
532
556
  -u, --username USERNAME
533
557
  Confluence user name.
534
- -a, --apikey, --api-key API_KEY
558
+ -a, --api-key API_KEY
535
559
  Confluence API key. Refer to documentation how to obtain one.
536
560
  -s, --space SPACE Confluence space key for pages to be published. If omitted, will default to user space.
537
561
  -l, --loglevel {debug,info,warning,error,critical}
@@ -550,8 +574,6 @@ options:
550
574
  --no-render-latex Inline LaTeX formulas in Confluence page. (Marketplace app required to display.)
551
575
  --diagram-output-format {png,svg}
552
576
  Format for rendering Mermaid and draw.io diagrams (default: 'png').
553
- --render-mermaid-format FORMAT
554
- Format for rendering Mermaid diagrams (default: 'png').
555
577
  --heading-anchors Place an anchor at each section heading with GitHub-style same-page identifiers.
556
578
  --no-heading-anchors Don't place an anchor at each section heading.
557
579
  --ignore-invalid-url Emit a warning but otherwise ignore relative URLs that point to ill-specified locations.
@@ -1,33 +1,34 @@
1
- markdown_to_confluence-0.4.5.dist-info/licenses/LICENSE,sha256=56L-Y0dyZwyVlINRJRz3PNw-ka-oLVaAq-7d8zo6qlc,1077
2
- md2conf/__init__.py,sha256=uvviya0xS1aCIW7IU3EfeI_QkC_d9T_PiVsLEMXo9S4,402
3
- md2conf/__main__.py,sha256=gQncJ-mkhRyQyhrZg-uJ1RnN8aGw-sr0c83ydunFNj0,11661
4
- md2conf/api.py,sha256=VjXD0da4de5YtPCbUCjK0k1oD6vl59IQLItEapj0pyM,37861
5
- md2conf/application.py,sha256=PZDPUpoKjKBPTHwgVO20pGzTwER3paZuQbI-2_TWBgE,8563
1
+ markdown_to_confluence-0.4.6.dist-info/licenses/LICENSE,sha256=56L-Y0dyZwyVlINRJRz3PNw-ka-oLVaAq-7d8zo6qlc,1077
2
+ md2conf/__init__.py,sha256=-uM9--fczADkWeiQ7PSL-iFlkeCxy7lnd2KhxG3yFso,402
3
+ md2conf/__main__.py,sha256=LiJ06zVr3wroMSLgkMf65ehraqJZgktvuN66dkBJaOI,10929
4
+ md2conf/api.py,sha256=RcltUP8KhrhrUuVg7SqBqp-AvGZymjMXtUxpzRPGGGc,40847
6
5
  md2conf/collection.py,sha256=EobgMRJgkYloWlY03NZJ52MRC_SGLpTVCHkltDbQyt0,837
7
- md2conf/converter.py,sha256=hWqbXYSFymMkvobh-f3uUO6JG28EWHU_7s0QYPI6NKM,61400
8
- md2conf/csf.py,sha256=WIzGrX-RXAkr4XsgLIUT11WM1qwhjgcXZHI_cALXpyM,6397
6
+ md2conf/converter.py,sha256=olL2FSop5TFxMfVXU4zWk1Lj_fwGN0c73F2ZYv8PU6s,63250
7
+ md2conf/csf.py,sha256=VlB0rYRZ8u-bNDdzEvNy2XrJSxaL-MW_hVRHxcoBluU,6409
9
8
  md2conf/domain.py,sha256=NpeGl-I9_rgKYCKKZT1Ygg3nl5U0-jJHYYrzDVpMSGQ,1965
10
9
  md2conf/drawio.py,sha256=3RJFFzlp5a7SNVNCnwO_HCDfMy0DqYQeXfHWRPInOVE,8527
10
+ md2conf/emoticon.py,sha256=P2L5oQvnRXeVifJQ3sJ2Ck-6ptbxumq2vsT-rM0W0Ms,484
11
11
  md2conf/entities.dtd,sha256=M6NzqL5N7dPs_eUA_6sDsiSLzDaAacrx9LdttiufvYU,30215
12
+ md2conf/environment.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
12
13
  md2conf/extra.py,sha256=VuMxuOnnC2Qwy6y52ukIxsaYhrZArRqMmRHRE4QZl8g,687
13
14
  md2conf/latex.py,sha256=yAClNclguPv-xWBMVWbqvYWLbyUHBVufc2aUzwyKHew,7586
14
15
  md2conf/local.py,sha256=mvp2kA_eo6JUQ_rlM7zDdEFgBPVxMr3VKP_X1nsLjHE,3747
15
16
  md2conf/markdown.py,sha256=czabU17tUfhSX1JQGiI_TrMrTmtoVThOwFu_To_Oi_w,3176
16
17
  md2conf/matcher.py,sha256=m5rZjYZSjhKfdeKS8JdPq7cG861Mc6rVZBkrIOZTHGE,6916
17
- md2conf/mermaid.py,sha256=7iziRC1Li3D85psR5NlnZ6BOePsOfFELgkNCAxahbZU,2240
18
+ md2conf/mermaid.py,sha256=hGrITJVvhHprjQVoezQ1nQeo6a_lqNihF8L-oJ4t5rc,2633
18
19
  md2conf/metadata.py,sha256=LzZM-oPNnzCULmLhF516tPlV5zZBknccwMHt8Nan-xg,1007
19
- md2conf/processor.py,sha256=z2d2KMPEYWaxflOtH2UTwrjzpPU8TtLSEUvor85ez1Q,9732
20
- md2conf/properties.py,sha256=RC1jY_TKVbOv2bJxXn27Fj4fNWzyoNUQt6ltgUyVQAQ,3987
20
+ md2conf/processor.py,sha256=62Yr33uNVEb11Q7SQ8m1KJHMoJozBPSzeUJQ0fwHig0,9733
21
+ md2conf/publisher.py,sha256=uSyYb9SOQzMkSGKKQqsoOL5S2CfkVIEbXaCx6CY0Suw,8696
21
22
  md2conf/puppeteer-config.json,sha256=-dMTAN_7kNTGbDlfXzApl0KJpAWna9YKZdwMKbpOb60,159
22
23
  md2conf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- md2conf/scanner.py,sha256=Cyvjab8tBvKgubttQvNagS8nailuTvFBqUGoiX5MNp8,5351
24
+ md2conf/scanner.py,sha256=APtENrcEgbslVfjjUnUyB81QpOmPoJgwfww0G7onNOY,6578
24
25
  md2conf/text.py,sha256=fHOrUaPXAjE4iRhHqFq-CiI-knpo4wvyHCWp0crewqA,1736
25
26
  md2conf/toc.py,sha256=hpqqDbFgNJg5-ul8qWjOglI3Am0sbwR-TLwGN5G9Qo0,2447
26
27
  md2conf/uri.py,sha256=KbLBdRFtZTQTZd8b4j0LtE8Pb68Ly0WkemF4iW-EAB4,1158
27
28
  md2conf/xml.py,sha256=Ybf3Ctt6EurVvel0eb1KezF33_e_cDpMwlUqHi4kNLE,5411
28
- markdown_to_confluence-0.4.5.dist-info/METADATA,sha256=zhCjkqQkp71Z28iRzGH-vLMczWaELkd-2FU_kiNv61k,33724
29
- markdown_to_confluence-0.4.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
30
- markdown_to_confluence-0.4.5.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
31
- markdown_to_confluence-0.4.5.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
32
- markdown_to_confluence-0.4.5.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
33
- markdown_to_confluence-0.4.5.dist-info/RECORD,,
29
+ markdown_to_confluence-0.4.6.dist-info/METADATA,sha256=zgUR1Bz7EnzbcAkZVPVKqL123Y0L4NeWzbhxyq5yVH4,34414
30
+ markdown_to_confluence-0.4.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ markdown_to_confluence-0.4.6.dist-info/entry_points.txt,sha256=F1zxa1wtEObtbHS-qp46330WVFLHdMnV2wQ-ZorRmX0,50
32
+ markdown_to_confluence-0.4.6.dist-info/top_level.txt,sha256=_FJfl_kHrHNidyjUOuS01ngu_jDsfc-ZjSocNRJnTzU,8
33
+ markdown_to_confluence-0.4.6.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
34
+ markdown_to_confluence-0.4.6.dist-info/RECORD,,
md2conf/__init__.py CHANGED
@@ -5,7 +5,7 @@ Parses Markdown files, converts Markdown content into the Confluence Storage For
5
5
  Confluence API endpoints to upload images and content.
6
6
  """
7
7
 
8
- __version__ = "0.4.5"
8
+ __version__ = "0.4.6"
9
9
  __author__ = "Levente Hunyadi"
10
10
  __copyright__ = "Copyright 2022-2025, Levente Hunyadi"
11
11
  __license__ = "MIT"
md2conf/__main__.py CHANGED
@@ -14,14 +14,15 @@ import logging
14
14
  import os.path
15
15
  import sys
16
16
  import typing
17
+ from io import StringIO
17
18
  from pathlib import Path
18
19
  from typing import Any, Iterable, Literal, Optional, Sequence, Union
19
20
 
20
21
  from . import __version__
21
22
  from .domain import ConfluenceDocumentOptions, ConfluencePageID
23
+ from .environment import ArgumentError, ConfluenceConnectionProperties, ConfluenceSiteProperties
22
24
  from .extra import override
23
25
  from .metadata import ConfluenceSiteMetadata
24
- from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceSiteProperties
25
26
 
26
27
 
27
28
  class Arguments(argparse.Namespace):
@@ -68,30 +69,6 @@ class KwargsAppendAction(argparse.Action):
68
69
  setattr(namespace, self.dest, d)
69
70
 
70
71
 
71
- def unsupported(prefer: str) -> type[argparse.Action]:
72
- class UnsupportedAction(argparse.Action):
73
- """Display an error for unsupported command-line options."""
74
-
75
- @override
76
- def __call__(
77
- self,
78
- parser: argparse.ArgumentParser,
79
- namespace: argparse.Namespace,
80
- values: Union[None, str, Sequence[Any]],
81
- option_string: Optional[str] = None,
82
- ) -> None:
83
- raise argparse.ArgumentError(
84
- self,
85
- f"this command-line option is no longer supported, use `--{prefer}`",
86
- )
87
-
88
- @override
89
- def __repr__(self) -> str:
90
- return f"{unsupported.__name__}({repr(prefer)})"
91
-
92
- return UnsupportedAction
93
-
94
-
95
72
  class PositionalOnlyHelpFormatter(argparse.HelpFormatter):
96
73
  def _format_usage(
97
74
  self,
@@ -112,7 +89,7 @@ class PositionalOnlyHelpFormatter(argparse.HelpFormatter):
112
89
  return usage_str
113
90
 
114
91
 
115
- def main() -> None:
92
+ def get_parser() -> argparse.ArgumentParser:
116
93
  parser = argparse.ArgumentParser(formatter_class=PositionalOnlyHelpFormatter)
117
94
  parser.prog = os.path.basename(os.path.dirname(__file__))
118
95
  parser.add_argument("--version", action="version", version=__version__)
@@ -127,7 +104,6 @@ def main() -> None:
127
104
  parser.add_argument("-u", "--username", help="Confluence user name.")
128
105
  parser.add_argument(
129
106
  "-a",
130
- "--apikey",
131
107
  "--api-key",
132
108
  dest="api_key",
133
109
  help="Confluence API key. Refer to documentation how to obtain one.",
@@ -228,12 +204,6 @@ def main() -> None:
228
204
  default="png",
229
205
  help="Format for rendering Mermaid and draw.io diagrams (default: 'png').",
230
206
  )
231
- parser.add_argument(
232
- "--render-mermaid-format",
233
- action=unsupported("diagram-output-format"),
234
- metavar="FORMAT",
235
- help="Format for rendering Mermaid diagrams (default: 'png').",
236
- )
237
207
  parser.add_argument(
238
208
  "--heading-anchors",
239
209
  action="store_true",
@@ -272,7 +242,18 @@ def main() -> None:
272
242
  default=False,
273
243
  help="Enable Confluence Web UI links. (Typically required for on-prem versions of Confluence.)",
274
244
  )
245
+ return parser
246
+
275
247
 
248
+ def get_help() -> str:
249
+ parser = get_parser()
250
+ with StringIO() as buf:
251
+ parser.print_help(file=buf)
252
+ return buf.getvalue()
253
+
254
+
255
+ def main() -> None:
256
+ parser = get_parser()
276
257
  args = Arguments()
277
258
  parser.parse_args(namespace=args)
278
259
 
@@ -316,7 +297,7 @@ def main() -> None:
316
297
  from requests import HTTPError, JSONDecodeError
317
298
 
318
299
  from .api import ConfluenceAPI
319
- from .application import Application
300
+ from .publisher import Publisher
320
301
 
321
302
  try:
322
303
  properties = ConfluenceConnectionProperties(
@@ -332,7 +313,7 @@ def main() -> None:
332
313
  parser.error(str(e))
333
314
  try:
334
315
  with ConfluenceAPI(properties) as api:
335
- Application(
316
+ Publisher(
336
317
  api,
337
318
  options,
338
319
  ).process(args.mdpath)
md2conf/api.py CHANGED
@@ -11,6 +11,8 @@ import enum
11
11
  import io
12
12
  import logging
13
13
  import mimetypes
14
+ import ssl
15
+ import sys
14
16
  import typing
15
17
  from dataclasses import dataclass
16
18
  from pathlib import Path
@@ -19,11 +21,18 @@ from typing import Any, Optional, TypeVar
19
21
  from urllib.parse import urlencode, urlparse, urlunparse
20
22
 
21
23
  import requests
24
+ from requests.adapters import HTTPAdapter
22
25
  from strong_typing.core import JsonType
23
26
  from strong_typing.serialization import DeserializerOptions, json_dump_string, json_to_object, object_to_json
24
27
 
28
+ from .environment import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
29
+ from .extra import override
25
30
  from .metadata import ConfluenceSiteMetadata
26
- from .properties import ArgumentError, ConfluenceConnectionProperties, ConfluenceError, PageError
31
+
32
+ if sys.version_info >= (3, 10):
33
+ import truststore
34
+ else:
35
+ import certifi
27
36
 
28
37
  T = TypeVar("T")
29
38
 
@@ -336,9 +345,33 @@ class ConfluenceUpdateAttachmentRequest:
336
345
  version: ConfluenceContentVersion
337
346
 
338
347
 
348
+ class TruststoreAdapter(HTTPAdapter):
349
+ """
350
+ Provides a general-case interface for HTTPS sessions to connect to HTTPS URLs.
351
+
352
+ This class implements the Transport Adapter interface in the Python library `requests`.
353
+
354
+ This class will usually be created by the :class:`requests.Session` class under the covers.
355
+ """
356
+
357
+ @override
358
+ def init_poolmanager(self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any) -> None:
359
+ """
360
+ Adapts the pool manager to use the provided SSL context instead of the default.
361
+ """
362
+
363
+ if sys.version_info >= (3, 10):
364
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
365
+ else:
366
+ ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
367
+ ctx.check_hostname = True
368
+ ctx.verify_mode = ssl.CERT_REQUIRED
369
+ super().init_poolmanager(connections, maxsize, block, ssl_context=ctx, **pool_kwargs) # type: ignore[no-untyped-call]
370
+
371
+
339
372
  class ConfluenceAPI:
340
373
  """
341
- Represents an active connection to a Confluence server.
374
+ Encapsulates operations that can be invoked via the [Confluence REST API](https://developer.atlassian.com/cloud/confluence/rest/v2/).
342
375
  """
343
376
 
344
377
  properties: ConfluenceConnectionProperties
@@ -348,7 +381,13 @@ class ConfluenceAPI:
348
381
  self.properties = properties or ConfluenceConnectionProperties()
349
382
 
350
383
  def __enter__(self) -> "ConfluenceSession":
384
+ """
385
+ Opens a connection to a Confluence server.
386
+ """
387
+
351
388
  session = requests.Session()
389
+ session.mount("https://", TruststoreAdapter())
390
+
352
391
  if self.properties.user_name:
353
392
  session.auth = (self.properties.user_name, self.properties.api_key)
354
393
  else:
@@ -372,6 +411,10 @@ class ConfluenceAPI:
372
411
  exc_val: Optional[BaseException],
373
412
  exc_tb: Optional[TracebackType],
374
413
  ) -> None:
414
+ """
415
+ Closes an open connection.
416
+ """
417
+
375
418
  if self.session is not None:
376
419
  self.session.close()
377
420
  self.session = None
@@ -379,7 +422,7 @@ class ConfluenceAPI:
379
422
 
380
423
  class ConfluenceSession:
381
424
  """
382
- Information about an open session to a Confluence server.
425
+ Represents an active connection to a Confluence server.
383
426
  """
384
427
 
385
428
  session: requests.Session
@@ -418,8 +461,31 @@ class ConfluenceSession:
418
461
  if not base_path:
419
462
  raise ArgumentError("Confluence base path not specified and cannot be inferred")
420
463
  self.site = ConfluenceSiteMetadata(domain, base_path, space_key)
464
+
421
465
  if not api_url:
422
- self.api_url = f"https://{self.site.domain}{self.site.base_path}"
466
+ LOGGER.info("Discovering Confluence REST API URL")
467
+ try:
468
+ # obtain cloud ID to build URL for access with scoped token
469
+ response = self.session.get(f"https://{self.site.domain}/_edge/tenant_info", headers={"Accept": "application/json"}, verify=True)
470
+ if response.text:
471
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
472
+ response.raise_for_status()
473
+ cloud_id = response.json()["cloudId"]
474
+
475
+ # try next-generation REST API URL
476
+ LOGGER.info("Probing scoped Confluence REST API URL")
477
+ self.api_url = f"https://api.atlassian.com/ex/confluence/{cloud_id}/"
478
+ url = self._build_url(ConfluenceVersion.VERSION_2, "/spaces", {"limit": "1"})
479
+ response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
480
+ if response.text:
481
+ LOGGER.debug("Received HTTP payload:\n%s", response.text)
482
+ response.raise_for_status()
483
+
484
+ LOGGER.info("Configured scoped Confluence REST API URL: %s", self.api_url)
485
+ except requests.exceptions.HTTPError:
486
+ # fall back to classic REST API URL
487
+ self.api_url = f"https://{self.site.domain}{self.site.base_path}"
488
+ LOGGER.info("Configured classic Confluence REST API URL: %s", self.api_url)
423
489
 
424
490
  def close(self) -> None:
425
491
  self.session.close()
@@ -454,7 +520,7 @@ class ConfluenceSession:
454
520
  "Executes an HTTP request via Confluence API."
455
521
 
456
522
  url = self._build_url(version, path, query)
457
- response = self.session.get(url, headers={"Accept": "application/json"})
523
+ response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
458
524
  if response.text:
459
525
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
460
526
  response.raise_for_status()
@@ -466,7 +532,7 @@ class ConfluenceSession:
466
532
  items: list[JsonType] = []
467
533
  url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
468
534
  while True:
469
- response = self.session.get(url, headers={"Accept": "application/json"})
535
+ response = self.session.get(url, headers={"Accept": "application/json"}, verify=True)
470
536
  response.raise_for_status()
471
537
 
472
538
  payload = typing.cast(dict[str, JsonType], response.json())
@@ -502,22 +568,16 @@ class ConfluenceSession:
502
568
  "Creates a new object via Confluence REST API."
503
569
 
504
570
  url, headers, data = self._build_request(version, path, body, response_type)
505
- response = self.session.post(
506
- url,
507
- data=data,
508
- headers=headers,
509
- )
571
+ response = self.session.post(url, data=data, headers=headers, verify=True)
572
+ response.raise_for_status()
510
573
  return response_cast(response_type, response)
511
574
 
512
575
  def _put(self, version: ConfluenceVersion, path: str, body: Any, response_type: type[T]) -> T:
513
576
  "Updates an existing object via Confluence REST API."
514
577
 
515
578
  url, headers, data = self._build_request(version, path, body, response_type)
516
- response = self.session.put(
517
- url,
518
- data=data,
519
- headers=headers,
520
- )
579
+ response = self.session.put(url, data=data, headers=headers, verify=True)
580
+ response.raise_for_status()
521
581
  return response_cast(response_type, response)
522
582
 
523
583
  def space_id_to_key(self, id: str) -> str:
@@ -687,6 +747,7 @@ class ConfluenceSession:
687
747
  "X-Atlassian-Token": "no-check",
688
748
  "Accept": "application/json",
689
749
  },
750
+ verify=True,
690
751
  )
691
752
  elif raw_data is not None:
692
753
  LOGGER.info("Uploading raw data: %s", attachment_name)
@@ -714,6 +775,7 @@ class ConfluenceSession:
714
775
  "X-Atlassian-Token": "no-check",
715
776
  "Accept": "application/json",
716
777
  },
778
+ verify=True,
717
779
  )
718
780
  else:
719
781
  raise NotImplementedError("parameter match not exhaustive")
@@ -875,6 +937,7 @@ class ConfluenceSession:
875
937
  "Content-Type": "application/json; charset=utf-8",
876
938
  "Accept": "application/json",
877
939
  },
940
+ verify=True,
878
941
  )
879
942
  response.raise_for_status()
880
943
  return _json_to_object(ConfluencePage, response.json())
@@ -892,7 +955,7 @@ class ConfluenceSession:
892
955
  # move to trash
893
956
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
894
957
  LOGGER.info("Moving page to trash: %s", page_id)
895
- response = self.session.delete(url)
958
+ response = self.session.delete(url, verify=True)
896
959
  response.raise_for_status()
897
960
 
898
961
  if purge:
@@ -900,7 +963,7 @@ class ConfluenceSession:
900
963
  query = {"purge": "true"}
901
964
  url = self._build_url(ConfluenceVersion.VERSION_2, path, query)
902
965
  LOGGER.info("Permanently deleting page: %s", page_id)
903
- response = self.session.delete(url)
966
+ response = self.session.delete(url, verify=True)
904
967
  response.raise_for_status()
905
968
 
906
969
  def page_exists(
@@ -935,6 +998,7 @@ class ConfluenceSession:
935
998
  "Content-Type": "application/json; charset=utf-8",
936
999
  "Accept": "application/json",
937
1000
  },
1001
+ verify=True,
938
1002
  )
939
1003
  response.raise_for_status()
940
1004
  data = typing.cast(dict[str, JsonType], response.json())
@@ -999,7 +1063,7 @@ class ConfluenceSession:
999
1063
  query = {"name": label.name}
1000
1064
 
1001
1065
  url = self._build_url(ConfluenceVersion.VERSION_1, path, query)
1002
- response = self.session.delete(url)
1066
+ response = self.session.delete(url, verify=True)
1003
1067
  if response.text:
1004
1068
  LOGGER.debug("Received HTTP payload:\n%s", response.text)
1005
1069
  response.raise_for_status()
@@ -1058,7 +1122,7 @@ class ConfluenceSession:
1058
1122
 
1059
1123
  path = f"/pages/{page_id}/properties/{property_id}"
1060
1124
  url = self._build_url(ConfluenceVersion.VERSION_2, path)
1061
- response = self.session.delete(url)
1125
+ response = self.session.delete(url, verify=True)
1062
1126
  response.raise_for_status()
1063
1127
 
1064
1128
  def update_content_property_for_page(
md2conf/converter.py CHANGED
@@ -21,17 +21,20 @@ 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 .properties import PageError
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
@@ -425,11 +428,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
425
428
  anchor.tail = heading.text
426
429
  heading.text = None
427
430
 
428
- def _warn_or_raise(self, msg: str) -> None:
431
+ def _anchor_warn_or_raise(self, anchor: ET._Element, msg: str) -> None:
429
432
  "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
433
 
431
434
  if self.options.ignore_invalid_url:
432
435
  LOGGER.warning(msg)
436
+ if anchor.text:
437
+ anchor.text = "❌ " + anchor.text
438
+ elif len(anchor) > 0:
439
+ anchor.text = "❌ "
433
440
  else:
434
441
  raise DocumentError(msg)
435
442
 
@@ -478,7 +485,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
478
485
 
479
486
  # look up the absolute path in the page metadata dictionary to discover the relative path within Confluence that should be used
480
487
  if not is_directory_within(absolute_path, self.root_dir):
481
- self._warn_or_raise(f"relative URL {url} points to outside root path: {self.root_dir}")
488
+ self._anchor_warn_or_raise(anchor, f"relative URL {url} points to outside root path: {self.root_dir}")
482
489
  return None
483
490
 
484
491
  if absolute_path.suffix == ".md":
@@ -493,7 +500,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
493
500
 
494
501
  link_metadata = self.page_metadata.get(absolute_path)
495
502
  if link_metadata is None:
496
- self._warn_or_raise(f"unable to find matching page for URL: {relative_url.geturl()}")
503
+ self._anchor_warn_or_raise(anchor, f"unable to find matching page for URL: {relative_url.geturl()}")
497
504
  return None
498
505
 
499
506
  relative_path = os.path.relpath(absolute_path, self.base_dir)
@@ -529,7 +536,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
529
536
  """
530
537
 
531
538
  if not absolute_path.exists():
532
- self._warn_or_raise(f"relative URL points to non-existing file: {absolute_path}")
539
+ self._anchor_warn_or_raise(anchor, f"relative URL points to non-existing file: {absolute_path}")
533
540
  return None
534
541
 
535
542
  file_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
@@ -603,7 +610,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
603
610
 
604
611
  absolute_path = self._verify_image_path(path)
605
612
  if absolute_path is None:
606
- return self._create_missing(path, attrs.caption)
613
+ return self._create_missing(path, attrs)
607
614
 
608
615
  if absolute_path.name.endswith(".drawio.png") or absolute_path.name.endswith(".drawio.svg"):
609
616
  return self._transform_drawio_image(absolute_path, attrs)
@@ -630,6 +637,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
630
637
 
631
638
  return AC_ELEM("image", attrs.as_dict(), *elements)
632
639
 
640
+ def _warn_or_raise(self, msg: str) -> None:
641
+ "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."
642
+
643
+ if self.options.ignore_invalid_url:
644
+ LOGGER.warning(msg)
645
+ else:
646
+ raise DocumentError(msg)
647
+
633
648
  def _verify_image_path(self, path: Path) -> Optional[Path]:
634
649
  "Checks whether an image path is safe to use."
635
650
 
@@ -749,30 +764,33 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
749
764
  *parameters,
750
765
  )
751
766
 
752
- def _create_missing(self, path: Path, caption: Optional[str]) -> ET._Element:
767
+ def _create_missing(self, path: Path, attrs: ImageAttributes) -> ET._Element:
753
768
  "A warning panel for a missing image."
754
769
 
755
- message = HTML.p("Missing image: ", HTML.code(path.as_posix()))
756
- if caption is not None:
757
- content = [
758
- AC_ELEM(
759
- "parameter",
760
- {AC_ATTR("name"): "title"},
761
- caption,
762
- ),
763
- AC_ELEM("rich-text-body", {}, message),
764
- ]
765
- else:
766
- content = [AC_ELEM("rich-text-body", {}, message)]
770
+ if attrs.context is FormattingContext.BLOCK:
771
+ message = HTML.p("❌ Missing image: ", HTML.code(path.as_posix()))
772
+ if attrs.caption is not None:
773
+ content = [
774
+ AC_ELEM(
775
+ "parameter",
776
+ {AC_ATTR("name"): "title"},
777
+ attrs.caption,
778
+ ),
779
+ AC_ELEM("rich-text-body", {}, message),
780
+ ]
781
+ else:
782
+ content = [AC_ELEM("rich-text-body", {}, message)]
767
783
 
768
- return AC_ELEM(
769
- "structured-macro",
770
- {
771
- AC_ATTR("name"): "warning",
772
- AC_ATTR("schema-version"): "1",
773
- },
774
- *content,
775
- )
784
+ return AC_ELEM(
785
+ "structured-macro",
786
+ {
787
+ AC_ATTR("name"): "warning",
788
+ AC_ATTR("schema-version"): "1",
789
+ },
790
+ *content,
791
+ )
792
+ else:
793
+ return HTML.span({"style": "color: rgb(255,86,48);"}, "❌ ", HTML.code(path.as_posix()))
776
794
 
777
795
  def _transform_code_block(self, code: ET._Element) -> ET._Element:
778
796
  "Transforms a code block."
@@ -811,6 +829,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
811
829
  AC_ELEM("plain-text-body", ET.CDATA(content)),
812
830
  )
813
831
 
832
+ def _extract_mermaid_config(self, content: str) -> Optional[MermaidConfigProperties]:
833
+ """Extract scale from Mermaid YAML front matter configuration."""
834
+ try:
835
+ properties = MermaidScanner().read(content)
836
+ return properties.config
837
+ except JsonTypeError as ex:
838
+ LOGGER.warning("Failed to extract Mermaid properties: %s", ex)
839
+ return None
840
+
814
841
  def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
815
842
  "Emits Confluence Storage Format XHTML for a Mermaid diagram read from an external file."
816
843
 
@@ -821,7 +848,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
821
848
  if self.options.render_mermaid:
822
849
  with open(absolute_path, "r", encoding="utf-8") as f:
823
850
  content = f.read()
824
- image_data = mermaid.render_diagram(content, self.options.diagram_output_format)
851
+ config = self._extract_mermaid_config(content)
852
+ image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
825
853
  image_filename = attachment_name(relative_path.with_suffix(f".{self.options.diagram_output_format}"))
826
854
  self.embedded_files[image_filename] = EmbeddedFileData(image_data, attrs.alt)
827
855
  return self._create_attached_image(image_filename, attrs)
@@ -834,7 +862,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
834
862
  "Emits Confluence Storage Format XHTML for a Mermaid diagram defined in a fenced code block."
835
863
 
836
864
  if self.options.render_mermaid:
837
- image_data = mermaid.render_diagram(content, self.options.diagram_output_format)
865
+ config = self._extract_mermaid_config(content)
866
+ image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
838
867
  image_hash = hashlib.md5(image_data).hexdigest()
839
868
  image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
840
869
  self.embedded_files[image_filename] = EmbeddedFileData(image_data)
@@ -1057,7 +1086,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1057
1086
  AC_ELEM("rich-text-body", {}, *list(blockquote)),
1058
1087
  )
1059
1088
 
1060
- def _transform_section(self, details: ET._Element) -> ET._Element:
1089
+ def _transform_collapsed(self, details: ET._Element) -> ET._Element:
1061
1090
  """
1062
1091
  Creates a collapsed section.
1063
1092
 
@@ -1115,11 +1144,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1115
1144
  unicode = elem.get("data-unicode", None)
1116
1145
  alt = elem.text or ""
1117
1146
 
1147
+ # emoji with a matching emoticon:
1118
1148
  # <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="&#128521;"/>
1149
+ #
1150
+ # emoji without a corresponding emoticon:
1151
+ # <ac:emoticon ac:name="blue-star" ac:emoji-shortname=":shield:" ac:emoji-id="1f6e1" ac:emoji-fallback="&#128737;"/>
1119
1152
  return AC_ELEM(
1120
1153
  "emoticon",
1121
1154
  {
1122
- AC_ATTR("name"): shortname,
1155
+ AC_ATTR("name"): emoji_to_emoticon(shortname),
1123
1156
  AC_ATTR("emoji-shortname"): f":{shortname}:",
1124
1157
  AC_ATTR("emoji-id"): unicode,
1125
1158
  AC_ATTR("emoji-fallback"): alt,
@@ -1240,7 +1273,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1240
1273
  Transforms a footnote reference.
1241
1274
 
1242
1275
  ```
1243
- <sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">1</a></sup>
1276
+ <sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">REF</a></sup>
1244
1277
  ```
1245
1278
  """
1246
1279
 
@@ -1498,7 +1531,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1498
1531
  # ...
1499
1532
  # </details>
1500
1533
  elif child.tag == "details" and len(child) > 1 and child[0].tag == "summary":
1501
- return self._transform_section(child)
1534
+ return self._transform_collapsed(child)
1502
1535
 
1503
1536
  # <ol>...</ol>
1504
1537
  elif child.tag == "ol":
@@ -1526,6 +1559,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1526
1559
 
1527
1560
  # <table>...</table>
1528
1561
  elif child.tag == "table":
1562
+ for td in child.iterdescendants("td", "th"):
1563
+ normalize_inline(td)
1529
1564
  child.set("data-layout", "default")
1530
1565
  return None
1531
1566
 
md2conf/csf.py CHANGED
@@ -152,7 +152,7 @@ def elements_to_string(root: ET._Element) -> str:
152
152
 
153
153
 
154
154
  def is_block_like(elem: ET._Element) -> bool:
155
- return elem.tag in ["div", "li", "ol", "p", "pre", "ul"]
155
+ return elem.tag in ["div", "li", "ol", "p", "pre", "td", "th", "ul"]
156
156
 
157
157
 
158
158
  def normalize_inline(elem: ET._Element) -> None:
md2conf/emoticon.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ Publish Markdown files to Confluence wiki.
3
+
4
+ Copyright 2022-2025, Levente Hunyadi
5
+
6
+ :see: https://github.com/hunyadi/md2conf
7
+ """
8
+
9
+ _EMOJI_TO_EMOTICON = {
10
+ "grinning": "laugh",
11
+ "heart": "heart",
12
+ "slight_frown": "sad",
13
+ "slight_smile": "smile",
14
+ "stuck_out_tongue": "cheeky",
15
+ "thumbsdown": "thumbs-down",
16
+ "thumbsup": "thumbs-up",
17
+ "wink": "wink",
18
+ }
19
+
20
+
21
+ def emoji_to_emoticon(shortname: str) -> str:
22
+ return _EMOJI_TO_EMOTICON.get(shortname) or "blue-star"
md2conf/mermaid.py CHANGED
@@ -11,11 +11,23 @@ import os
11
11
  import os.path
12
12
  import shutil
13
13
  import subprocess
14
- from typing import Literal
14
+ from dataclasses import dataclass
15
+ from typing import Literal, Optional
15
16
 
16
17
  LOGGER = logging.getLogger(__name__)
17
18
 
18
19
 
20
+ @dataclass
21
+ class MermaidConfigProperties:
22
+ """
23
+ Configuration options for rendering Mermaid diagrams.
24
+
25
+ :param scale: Scaling factor for the rendered diagram.
26
+ """
27
+
28
+ scale: Optional[float] = None
29
+
30
+
19
31
  def is_docker() -> bool:
20
32
  "True if the application is running in a Docker container."
21
33
 
@@ -44,9 +56,12 @@ def has_mmdc() -> bool:
44
56
  return shutil.which(executable) is not None
45
57
 
46
58
 
47
- def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
59
+ def render_diagram(source: str, output_format: Literal["png", "svg"] = "png", config: Optional[MermaidConfigProperties] = None) -> bytes:
48
60
  "Generates a PNG or SVG image from a Mermaid diagram source."
49
61
 
62
+ if config is None:
63
+ config = MermaidConfigProperties()
64
+
50
65
  cmd = [
51
66
  get_mmdc(),
52
67
  "--input",
@@ -58,7 +73,7 @@ def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") ->
58
73
  "--backgroundColor",
59
74
  "transparent",
60
75
  "--scale",
61
- "2",
76
+ str(config.scale or 2),
62
77
  ]
63
78
  root = os.path.dirname(__file__)
64
79
  if is_docker():
md2conf/processor.py CHANGED
@@ -16,9 +16,9 @@ from typing import Iterable, Optional
16
16
  from .collection import ConfluencePageCollection
17
17
  from .converter import ConfluenceDocument
18
18
  from .domain import ConfluenceDocumentOptions, ConfluencePageID
19
+ from .environment import ArgumentError
19
20
  from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
20
21
  from .metadata import ConfluenceSiteMetadata
21
- from .properties import ArgumentError
22
22
  from .scanner import Scanner
23
23
 
24
24
  LOGGER = logging.getLogger(__name__)
@@ -14,10 +14,10 @@ from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession,
14
14
  from .converter import ConfluenceDocument, attachment_name, get_volatile_attributes, get_volatile_elements
15
15
  from .csf import AC_ATTR, elements_from_string
16
16
  from .domain import ConfluenceDocumentOptions, ConfluencePageID
17
+ from .environment import PageError
17
18
  from .extra import override, path_relative_to
18
19
  from .metadata import ConfluencePageMetadata
19
20
  from .processor import Converter, DocumentNode, Processor, ProcessorFactory
20
- from .properties import PageError
21
21
  from .xml import is_xml_equal, unwrap_substitute
22
22
 
23
23
  LOGGER = logging.getLogger(__name__)
@@ -73,20 +73,23 @@ class SynchronizingProcessor(Processor):
73
73
  # verify if page exists
74
74
  page = self.api.get_page_properties(node.page_id)
75
75
  update = False
76
- elif node.title is not None:
77
- # look up page by title
78
- page = self.api.get_or_create_page(node.title, parent_id.page_id)
76
+ else:
77
+ if node.title is not None:
78
+ # use title extracted from source metadata
79
+ title = node.title
80
+ else:
81
+ # assign an auto-generated title
82
+ digest = self._generate_hash(node.absolute_path)
83
+ title = f"{node.absolute_path.stem} [{digest}]"
84
+
85
+ # look up page by (possibly auto-generated) title
86
+ page = self.api.get_or_create_page(title, parent_id.page_id)
79
87
 
80
88
  if page.status is ConfluenceStatus.ARCHIVED:
89
+ # user has archived a page with this (auto-generated) title
81
90
  raise PageError(f"unable to update archived page with ID {page.id}")
82
91
 
83
92
  update = True
84
- else:
85
- # always create a new page
86
- digest = self._generate_hash(node.absolute_path)
87
- title = f"{node.absolute_path.stem} [{digest}]"
88
- page = self.api.create_page(parent_id.page_id, title, "")
89
- update = True
90
93
 
91
94
  space_key = self.api.space_id_to_key(page.spaceId)
92
95
  if update:
@@ -215,7 +218,7 @@ class SynchronizingProcessorFactory(ProcessorFactory):
215
218
  return SynchronizingProcessor(self.api, self.options, root_dir)
216
219
 
217
220
 
218
- class Application(Converter):
221
+ class Publisher(Converter):
219
222
  """
220
223
  The entry point for Markdown to Confluence conversion.
221
224
 
md2conf/scanner.py CHANGED
@@ -15,6 +15,8 @@ import yaml
15
15
  from strong_typing.core import JsonType
16
16
  from strong_typing.serialization import DeserializerOptions, json_to_object
17
17
 
18
+ from .mermaid import MermaidConfigProperties
19
+
18
20
  T = TypeVar("T")
19
21
 
20
22
 
@@ -155,3 +157,47 @@ class Scanner:
155
157
  properties=properties,
156
158
  text=text,
157
159
  )
160
+
161
+
162
+ @dataclass
163
+ class MermaidProperties:
164
+ """
165
+ An object that holds the front-matter properties structure for Mermaid diagrams.
166
+
167
+ :param title: The title of the diagram.
168
+ :param config: Configuration options for rendering.
169
+ """
170
+
171
+ title: Optional[str] = None
172
+ config: Optional[MermaidConfigProperties] = None
173
+
174
+
175
+ class MermaidScanner:
176
+ """
177
+ Extracts properties from the JSON/YAML front-matter of a Mermaid diagram.
178
+ """
179
+
180
+ def read(self, content: str) -> MermaidProperties:
181
+ """
182
+ Extracts rendering preferences from a Mermaid front-matter content.
183
+
184
+ ```
185
+ ---
186
+ title: Tiny flow diagram
187
+ config:
188
+ scale: 1
189
+ ---
190
+ flowchart LR
191
+ A[Component A] --> B[Component B]
192
+ B --> C[Component C]
193
+ ```
194
+ """
195
+
196
+ properties, text = extract_frontmatter_properties(content)
197
+ if properties is not None:
198
+ front_matter = _json_to_object(MermaidProperties, properties)
199
+ config = front_matter.config or MermaidConfigProperties()
200
+
201
+ return MermaidProperties(title=front_matter.title, config=config)
202
+
203
+ return MermaidProperties()
File without changes