conjira-cli 0.2.6__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {conjira_cli-0.2.6/src/conjira_cli.egg-info → conjira_cli-0.2.7}/PKG-INFO +3 -3
  2. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/README.md +2 -2
  3. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/pyproject.toml +1 -1
  4. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/__init__.py +1 -1
  5. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/markdown_export.py +28 -4
  6. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/markdown_import.py +44 -6
  7. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/section_edit.py +36 -9
  8. {conjira_cli-0.2.6 → conjira_cli-0.2.7/src/conjira_cli.egg-info}/PKG-INFO +3 -3
  9. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_markdown_export.py +140 -2
  10. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_markdown_import.py +28 -0
  11. conjira_cli-0.2.7/tests/test_section_edit.py +180 -0
  12. conjira_cli-0.2.6/tests/test_section_edit.py +0 -93
  13. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/LICENSE +0 -0
  14. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/setup.cfg +0 -0
  15. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/setup.py +0 -0
  16. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/__main__.py +0 -0
  17. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/cli.py +0 -0
  18. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/client.py +0 -0
  19. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/config.py +0 -0
  20. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/footer_comments.py +0 -0
  21. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/inline_comments.py +0 -0
  22. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/setup_macos.py +0 -0
  23. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/tree_export.py +0 -0
  24. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/SOURCES.txt +0 -0
  25. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/dependency_links.txt +0 -0
  26. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/entry_points.txt +0 -0
  27. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/top_level.txt +0 -0
  28. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_cli.py +0 -0
  29. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_client.py +0 -0
  30. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_config.py +0 -0
  31. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_inline_comments.py +0 -0
  32. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_setup_macos.py +0 -0
  33. {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_tree_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conjira-cli
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
5
5
  Author: quanttraderkim
6
6
  License-Expression: MIT
@@ -47,7 +47,7 @@ If your team uses self-hosted Confluence and Jira, official cloud-native connect
47
47
 
48
48
  `conjira-cli` is built for that gap. It helps when you want to read Confluence pages, export them to Markdown, refresh stale exports from the live wiki, summarize inline comment threads, search Jira with JQL, or create and update content without hardcoding PATs into source files or chat transcripts.
49
49
 
50
- For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
50
+ For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers fenced code blocks (Confluence `code` macro), LaTeX formulas with ```` ```math ```` fences (Confluence `mathblock` macro), Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
51
51
 
52
52
  ## What you can do with it
53
53
 
@@ -484,7 +484,7 @@ The recommended pattern is to set `CONFLUENCE_EXPORT_DEFAULT_DIR` to an inbox or
484
484
 
485
485
  ## Markdown import notes
486
486
 
487
- Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
487
+ Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`code`, ```` ```math ```` for `mathblock`, `mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). Export keeps code, math, and other plain-text macro bodies as fenced blocks, so export → edit → upload does not drop formulas. It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
488
488
 
489
489
  Use `--body-file` and `--append-file` only for storage HTML files. If your source file is Markdown, use `--body-markdown-file` or `--append-markdown-file` so the CLI converts it before upload.
490
490
 
@@ -18,7 +18,7 @@ If your team uses self-hosted Confluence and Jira, official cloud-native connect
18
18
 
19
19
  `conjira-cli` is built for that gap. It helps when you want to read Confluence pages, export them to Markdown, refresh stale exports from the live wiki, summarize inline comment threads, search Jira with JQL, or create and update content without hardcoding PATs into source files or chat transcripts.
20
20
 
21
- For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
21
+ For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers fenced code blocks (Confluence `code` macro), LaTeX formulas with ```` ```math ```` fences (Confluence `mathblock` macro), Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
22
22
 
23
23
  ## What you can do with it
24
24
 
@@ -455,7 +455,7 @@ The recommended pattern is to set `CONFLUENCE_EXPORT_DEFAULT_DIR` to an inbox or
455
455
 
456
456
  ## Markdown import notes
457
457
 
458
- Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
458
+ Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`code`, ```` ```math ```` for `mathblock`, `mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). Export keeps code, math, and other plain-text macro bodies as fenced blocks, so export → edit → upload does not drop formulas. It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
459
459
 
460
460
  Use `--body-file` and `--append-file` only for storage HTML files. If your source file is Markdown, use `--body-markdown-file` or `--append-markdown-file` so the CLI converts it before upload.
461
461
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conjira-cli"
7
- version = "0.2.6"
7
+ version = "0.2.7"
8
8
  description = "Unofficial agent-friendly CLI for self-hosted Confluence and Jira"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.7"
@@ -212,11 +212,17 @@ class MarkdownExporter:
212
212
  if macro_name == "toc":
213
213
  return ""
214
214
  if self.mermaid_macro_name and macro_name == self.mermaid_macro_name:
215
- return self._render_mermaid_macro(elem)
215
+ return self._render_fenced_macro(elem, "mermaid")
216
216
  if macro_name == "status":
217
217
  return self._render_status_token(elem) + "\n\n"
218
218
  if macro_name in _CALLOUT_MACRO_NAMES:
219
219
  return self._render_callout_macro(elem, macro_name)
220
+ if macro_name == "code":
221
+ return self._render_fenced_macro(elem, self._extract_macro_parameter(elem, "language"))
222
+ if macro_name == "mathblock":
223
+ return self._render_fenced_macro(elem, "math")
224
+ if self._has_only_plain_text_body(elem):
225
+ return self._render_fenced_macro(elem, "")
220
226
  return self._render_children(elem, indent=indent)
221
227
  if name in {"h1", "h2", "h3", "h4", "h5", "h6"}:
222
228
  level = int(name[1])
@@ -295,6 +301,8 @@ class MarkdownExporter:
295
301
  macro_name = elem.attrib.get("{urn:ac}name")
296
302
  if macro_name == "status":
297
303
  return self._render_status_token(elem)
304
+ if self._is_fenced_macro(elem):
305
+ return "\n" + self._render_block(elem, indent=0).strip() + "\n"
298
306
  return ""
299
307
  if name in {"ul", "ol", "table"}:
300
308
  return "\n" + self._render_block(elem, indent=1).strip() + "\n"
@@ -317,7 +325,7 @@ class MarkdownExporter:
317
325
 
318
326
  for grandchild in list(child):
319
327
  grandchild_name = _local_name(grandchild.tag)
320
- if grandchild_name in {"ul", "ol", "table"}:
328
+ if grandchild_name in {"ul", "ol", "table"} or self._is_fenced_macro(grandchild):
321
329
  nested_text = self._render_block(grandchild, indent=indent + 1).rstrip()
322
330
  if not nested_text:
323
331
  continue
@@ -512,9 +520,25 @@ class MarkdownExporter:
512
520
  )
513
521
  return f"![{attachment_name}]({url})\n\n"
514
522
 
515
- def _render_mermaid_macro(self, elem: ET.Element) -> str:
523
+ def _is_fenced_macro(self, elem: ET.Element) -> bool:
524
+ if _local_name(elem.tag) != "structured-macro":
525
+ return False
526
+ macro_name = elem.attrib.get("{urn:ac}name")
527
+ if macro_name in {"code", "mathblock"}:
528
+ return True
529
+ if self.mermaid_macro_name and macro_name == self.mermaid_macro_name:
530
+ return True
531
+ return self._has_only_plain_text_body(elem)
532
+
533
+ @staticmethod
534
+ def _has_only_plain_text_body(elem: ET.Element) -> bool:
535
+ child_names = {_local_name(child.tag) for child in list(elem)}
536
+ return "plain-text-body" in child_names and "rich-text-body" not in child_names
537
+
538
+ def _render_fenced_macro(self, elem: ET.Element, language: str) -> str:
516
539
  text = self._extract_macro_plain_text_body(elem).strip("\n")
517
- return f"```mermaid\n{text}\n```\n\n" if text else "```mermaid\n```\n\n"
540
+ fence_language = "" if language.strip().lower() in {"", "none"} else language.strip()
541
+ return f"```{fence_language}\n{text}\n```\n\n"
518
542
 
519
543
  def _render_callout_macro(self, elem: ET.Element, macro_name: str) -> str:
520
544
  title = self._extract_macro_parameter(elem, "title")
@@ -126,7 +126,7 @@ def _parse_blocks(
126
126
  continue
127
127
 
128
128
  if _LIST_ITEM_RE.match(line):
129
- block, i = _parse_list(lines, i, current_indent)
129
+ block, i = _parse_list(lines, i, current_indent, mermaid_macro_name=mermaid_macro_name)
130
130
  parts.append(block)
131
131
  continue
132
132
 
@@ -175,7 +175,9 @@ def _render_fenced_block(
175
175
  mermaid_macro_name: Optional[str],
176
176
  ) -> str:
177
177
  if mermaid_macro_name and language.lower() == "mermaid":
178
- return _render_mermaid_macro(code, mermaid_macro_name)
178
+ return _render_plain_text_macro(code, mermaid_macro_name)
179
+ if language.lower() == "math":
180
+ return _render_plain_text_macro(code, "mathblock")
179
181
  return _render_code_macro(code, language)
180
182
 
181
183
 
@@ -191,7 +193,7 @@ def _render_code_macro(code: str, language: str) -> str:
191
193
  ).format(safe_language, cdata_code)
192
194
 
193
195
 
194
- def _render_mermaid_macro(code: str, macro_name: str) -> str:
196
+ def _render_plain_text_macro(code: str, macro_name: str) -> str:
195
197
  safe_macro_name = html.escape(macro_name, quote=True)
196
198
  cdata_code = code.replace("]]>", "]]]]><![CDATA[>")
197
199
  return (
@@ -282,7 +284,13 @@ def _render_callout_macro(*, macro_name: str, title: str, body_html: str) -> str
282
284
  )
283
285
 
284
286
 
285
- def _parse_list(lines: list[str], start: int, indent: int) -> tuple[str, int]:
287
+ def _parse_list(
288
+ lines: list[str],
289
+ start: int,
290
+ indent: int,
291
+ *,
292
+ mermaid_macro_name: Optional[str] = None,
293
+ ) -> tuple[str, int]:
286
294
  first_match = _LIST_ITEM_RE.match(lines[start])
287
295
  if first_match is None:
288
296
  raise ValueError("List parsing requires a list item")
@@ -302,7 +310,7 @@ def _parse_list(lines: list[str], start: int, indent: int) -> tuple[str, int]:
302
310
  if current_indent < indent or current_ordered != ordered:
303
311
  break
304
312
  if current_indent > indent:
305
- nested, i = _parse_list(lines, i, current_indent)
313
+ nested, i = _parse_list(lines, i, current_indent, mermaid_macro_name=mermaid_macro_name)
306
314
  if items:
307
315
  items[-1] = items[-1].replace("</li>", nested + "</li>", 1)
308
316
  continue
@@ -326,13 +334,22 @@ def _parse_list(lines: list[str], start: int, indent: int) -> tuple[str, int]:
326
334
  if nested_indent == indent and nested_ordered == ordered:
327
335
  break
328
336
  if nested_indent > indent:
329
- nested_html, i = _parse_list(lines, i, nested_indent)
337
+ nested_html, i = _parse_list(lines, i, nested_indent, mermaid_macro_name=mermaid_macro_name)
330
338
  content_parts.append(nested_html)
331
339
  continue
332
340
  if nested_indent < indent:
333
341
  break
334
342
 
335
343
  continuation_indent = _leading_spaces(next_line)
344
+ if continuation_indent > indent and next_line.strip().startswith("```"):
345
+ block_html, i = _parse_nested_fenced_code(
346
+ lines,
347
+ i,
348
+ fence_indent=continuation_indent,
349
+ mermaid_macro_name=mermaid_macro_name,
350
+ )
351
+ content_parts.append(block_html)
352
+ continue
336
353
  if continuation_indent > indent:
337
354
  content_parts.append(" " + _render_inline(next_line.strip()))
338
355
  i += 1
@@ -344,6 +361,27 @@ def _parse_list(lines: list[str], start: int, indent: int) -> tuple[str, int]:
344
361
  return "<{0}>{1}</{0}>".format(tag, "".join(items)), i
345
362
 
346
363
 
364
+ def _parse_nested_fenced_code(
365
+ lines: list[str],
366
+ start: int,
367
+ *,
368
+ fence_indent: int,
369
+ mermaid_macro_name: Optional[str],
370
+ ) -> tuple[str, int]:
371
+ """Parse a fenced block indented under a list item, dedenting it by the fence indent."""
372
+ fence = lines[start].strip()[:3]
373
+ dedented: list[str] = []
374
+ i = start
375
+ while i < len(lines):
376
+ line = lines[i]
377
+ dedented.append(line[fence_indent:] if line[:fence_indent].strip() == "" else line.lstrip())
378
+ i += 1
379
+ if i - 1 > start and line.strip().startswith(fence):
380
+ break
381
+ block_html, _ = _parse_fenced_code(dedented, 0, mermaid_macro_name=mermaid_macro_name)
382
+ return block_html, i
383
+
384
+
347
385
  def _parse_table(lines: list[str], start: int) -> tuple[str, int]:
348
386
  rows: list[list[str]] = []
349
387
  i = start
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import re
5
+ import uuid
5
6
  import xml.etree.ElementTree as ET
6
7
  from dataclasses import dataclass
7
8
 
@@ -11,6 +12,7 @@ ET.register_namespace("ri", "urn:ri")
11
12
 
12
13
 
13
14
  _WRAPPED_ROOT_PREFIX = '<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">'
15
+ _CDATA_RE = re.compile(r"<!\[CDATA\[.*?\]\]>", re.DOTALL)
14
16
 
15
17
 
16
18
  class SectionEditError(ValueError):
@@ -36,13 +38,37 @@ class HeadingInsertionResult:
36
38
  updated_body_html: str
37
39
 
38
40
 
41
+ class _CdataVault:
42
+ """Hide CDATA sections from the XML parser and restore them after serialization.
43
+
44
+ ElementTree folds ``<![CDATA[...]]>`` into ordinary text and writes it back
45
+ escaped, which Confluence no longer treats as a plain-text macro body.
46
+ """
47
+
48
+ def __init__(self) -> None:
49
+ self._token = "conjira-cdata-" + uuid.uuid4().hex
50
+ self._sections: list[str] = []
51
+ self._placeholder_re = re.compile(re.escape(self._token) + r"-(\d+)-")
52
+
53
+ def protect(self, html: str) -> str:
54
+ def _stash(match: re.Match[str]) -> str:
55
+ self._sections.append(match.group(0))
56
+ return "{0}-{1}-".format(self._token, len(self._sections) - 1)
57
+
58
+ return _CDATA_RE.sub(_stash, html)
59
+
60
+ def restore(self, html: str) -> str:
61
+ return self._placeholder_re.sub(lambda match: self._sections[int(match.group(1))], html)
62
+
63
+
39
64
  def replace_section_html(
40
65
  body_html: str,
41
66
  *,
42
67
  heading: str,
43
68
  replacement_html: str,
44
69
  ) -> SectionReplacementResult:
45
- root = _parse_fragment(body_html)
70
+ vault = _CdataVault()
71
+ root = _parse_fragment(vault.protect(body_html))
46
72
  children = list(root)
47
73
  match_index, match_elem, match_level = _find_unique_heading(
48
74
  children,
@@ -59,7 +85,7 @@ def replace_section_html(
59
85
  old_section_children = children[match_index + 1 : end_index]
60
86
  old_section_html = _serialize_elements(old_section_children)
61
87
 
62
- new_root = _parse_fragment(replacement_html)
88
+ new_root = _parse_fragment(vault.protect(replacement_html))
63
89
  new_children = [copy.deepcopy(child) for child in list(new_root)]
64
90
 
65
91
  for child in old_section_children:
@@ -72,9 +98,9 @@ def replace_section_html(
72
98
  heading=heading,
73
99
  matched_heading=_element_text(match_elem),
74
100
  heading_level=match_level,
75
- old_section_html=old_section_html,
76
- new_section_html=_serialize_elements(new_children),
77
- updated_body_html=_serialize_root(root),
101
+ old_section_html=vault.restore(old_section_html),
102
+ new_section_html=vault.restore(_serialize_elements(new_children)),
103
+ updated_body_html=vault.restore(_serialize_root(root)),
78
104
  )
79
105
 
80
106
 
@@ -84,7 +110,8 @@ def insert_after_heading_html(
84
110
  heading: str,
85
111
  inserted_html: str,
86
112
  ) -> HeadingInsertionResult:
87
- root = _parse_fragment(body_html)
113
+ vault = _CdataVault()
114
+ root = _parse_fragment(vault.protect(body_html))
88
115
  children = list(root)
89
116
  match_index, match_elem, match_level = _find_unique_heading(
90
117
  children,
@@ -92,7 +119,7 @@ def insert_after_heading_html(
92
119
  action_name="insert-after-heading",
93
120
  )
94
121
 
95
- new_root = _parse_fragment(inserted_html)
122
+ new_root = _parse_fragment(vault.protect(inserted_html))
96
123
  new_children = [copy.deepcopy(child) for child in list(new_root)]
97
124
 
98
125
  insert_at = match_index + 1
@@ -103,8 +130,8 @@ def insert_after_heading_html(
103
130
  heading=heading,
104
131
  matched_heading=_element_text(match_elem),
105
132
  heading_level=match_level,
106
- inserted_html=_serialize_elements(new_children),
107
- updated_body_html=_serialize_root(root),
133
+ inserted_html=vault.restore(_serialize_elements(new_children)),
134
+ updated_body_html=vault.restore(_serialize_root(root)),
108
135
  )
109
136
 
110
137
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conjira-cli
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
5
5
  Author: quanttraderkim
6
6
  License-Expression: MIT
@@ -47,7 +47,7 @@ If your team uses self-hosted Confluence and Jira, official cloud-native connect
47
47
 
48
48
  `conjira-cli` is built for that gap. It helps when you want to read Confluence pages, export them to Markdown, refresh stale exports from the live wiki, summarize inline comment threads, search Jira with JQL, or create and update content without hardcoding PATs into source files or chat transcripts.
49
49
 
50
- For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
50
+ For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers fenced code blocks (Confluence `code` macro), LaTeX formulas with ```` ```math ```` fences (Confluence `mathblock` macro), Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
51
51
 
52
52
  ## What you can do with it
53
53
 
@@ -484,7 +484,7 @@ The recommended pattern is to set `CONFLUENCE_EXPORT_DEFAULT_DIR` to an inbox or
484
484
 
485
485
  ## Markdown import notes
486
486
 
487
- Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
487
+ Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`code`, ```` ```math ```` for `mathblock`, `mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). Export keeps code, math, and other plain-text macro bodies as fenced blocks, so export → edit → upload does not drop formulas. It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
488
488
 
489
489
  Use `--body-file` and `--append-file` only for storage HTML files. If your source file is Markdown, use `--body-markdown-file` or `--append-markdown-file` so the CLI converts it before upload.
490
490
 
@@ -161,7 +161,7 @@ class MarkdownExportTests(unittest.TestCase):
161
161
 
162
162
  self.assertIn("```mermaid\ngraph TD\nA-->B\n```", result)
163
163
 
164
- def test_mermaid_macro_is_ignored_without_config(self) -> None:
164
+ def test_mermaid_macro_keeps_source_as_plain_fence_without_config(self) -> None:
165
165
  exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
166
166
  html = (
167
167
  '<ac:structured-macro ac:name="mermaid-macro" ac:schema-version="1">'
@@ -171,7 +171,7 @@ class MarkdownExportTests(unittest.TestCase):
171
171
 
172
172
  result = exporter.convert_fragment(html)
173
173
 
174
- self.assertEqual(result.strip(), "")
174
+ self.assertEqual(result.strip(), "```\ngraph TD\nA-->B\n```")
175
175
 
176
176
  def test_callout_macro_renders_as_markdown_callout(self) -> None:
177
177
  exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
@@ -295,3 +295,141 @@ class MarkdownExportTests(unittest.TestCase):
295
295
  for line in result.splitlines():
296
296
  if "Review" in line and line.strip().startswith("|"):
297
297
  self.assertIn("\\|", line, "Pipe in list item should be escaped")
298
+
299
+
300
+ class PlainTextMacroExportTests(unittest.TestCase):
301
+ def setUp(self) -> None:
302
+ self.exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="12345")
303
+
304
+ def test_code_macro_renders_as_fenced_block_with_language(self) -> None:
305
+ html = (
306
+ "<h2>Formula</h2>"
307
+ '<ac:structured-macro ac:name="code" ac:schema-version="1">'
308
+ '<ac:parameter ac:name="language">text</ac:parameter>'
309
+ '<ac:parameter ac:name="theme">Default</ac:parameter>'
310
+ "<ac:plain-text-body><![CDATA[Credit = Cost × E\n + InfraCredit]]></ac:plain-text-body>"
311
+ "</ac:structured-macro>"
312
+ )
313
+
314
+ result = self.exporter.convert_fragment(html)
315
+
316
+ self.assertIn("## Formula", result)
317
+ self.assertIn("```text\nCredit = Cost × E\n + InfraCredit\n```", result)
318
+ self.assertNotIn("Default", result)
319
+
320
+ def test_code_macro_without_language_renders_as_bare_fence(self) -> None:
321
+ for language_xml in ("", '<ac:parameter ac:name="language">none</ac:parameter>'):
322
+ html = (
323
+ '<ac:structured-macro ac:name="code" ac:schema-version="1">'
324
+ + language_xml
325
+ + "<ac:plain-text-body><![CDATA[plain body]]></ac:plain-text-body>"
326
+ "</ac:structured-macro>"
327
+ )
328
+
329
+ result = self.exporter.convert_fragment(html)
330
+
331
+ self.assertIn("```\nplain body\n```", result)
332
+ self.assertNotIn("```none", result)
333
+
334
+ def test_mathblock_macro_renders_as_math_fence_with_latex_verbatim(self) -> None:
335
+ html = (
336
+ '<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
337
+ '<ac:parameter ac:name="alignment">left</ac:parameter>'
338
+ "<ac:plain-text-body><![CDATA[\\text{Credit} = \\sum_{i=1}^{N} a_i \\times E]]></ac:plain-text-body>"
339
+ "</ac:structured-macro>"
340
+ )
341
+
342
+ result = self.exporter.convert_fragment(html)
343
+
344
+ self.assertIn("```math\n\\text{Credit} = \\sum_{i=1}^{N} a_i \\times E\n```", result)
345
+ self.assertNotIn("left", result)
346
+
347
+ def test_unknown_plain_text_macro_renders_as_plain_fence_but_rich_macros_keep_children(self) -> None:
348
+ html = (
349
+ '<ac:structured-macro ac:name="noformat" ac:schema-version="1">'
350
+ "<ac:plain-text-body><![CDATA[raw text]]></ac:plain-text-body>"
351
+ "</ac:structured-macro>"
352
+ '<ac:structured-macro ac:name="panel" ac:schema-version="1">'
353
+ "<ac:rich-text-body><p>Panel text</p></ac:rich-text-body>"
354
+ "</ac:structured-macro>"
355
+ )
356
+
357
+ result = self.exporter.convert_fragment(html)
358
+
359
+ self.assertIn("```\nraw text\n```", result)
360
+ self.assertIn("Panel text", result)
361
+ self.assertNotIn("```\nPanel text", result)
362
+
363
+ def test_code_and_math_macros_survive_export_import_round_trip(self) -> None:
364
+ from conjira_cli.markdown_import import markdown_to_storage_html
365
+
366
+ html = (
367
+ "<h2>Formula</h2>"
368
+ '<ac:structured-macro ac:name="code" ac:schema-version="1">'
369
+ '<ac:parameter ac:name="language">text</ac:parameter>'
370
+ "<ac:plain-text-body><![CDATA[Credit = Cost × E]]></ac:plain-text-body>"
371
+ "</ac:structured-macro>"
372
+ '<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
373
+ "<ac:plain-text-body><![CDATA[\\text{Credit} = \\text{Cost} \\times E]]></ac:plain-text-body>"
374
+ "</ac:structured-macro>"
375
+ )
376
+
377
+ round_tripped = markdown_to_storage_html(self.exporter.convert_fragment(html))
378
+
379
+ self.assertIn('<ac:structured-macro ac:name="code" ac:schema-version="1">', round_tripped)
380
+ self.assertIn('<ac:parameter ac:name="language">text</ac:parameter>', round_tripped)
381
+ self.assertIn("<![CDATA[Credit = Cost × E]]>", round_tripped)
382
+ self.assertIn('<ac:structured-macro ac:name="mathblock" ac:schema-version="1">', round_tripped)
383
+ self.assertIn("<![CDATA[\\text{Credit} = \\text{Cost} \\times E]]>", round_tripped)
384
+
385
+ def test_plain_text_macro_inside_list_item_renders_as_nested_fence(self) -> None:
386
+ html = (
387
+ "<ul><li>Ledger"
388
+ '<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
389
+ "<ac:plain-text-body><![CDATA[\\text{Ledger} = \\text{Credit}]]></ac:plain-text-body>"
390
+ "</ac:structured-macro>"
391
+ "</li><li>Next item</li></ul>"
392
+ )
393
+
394
+ result = self.exporter.convert_fragment(html)
395
+
396
+ self.assertIn("- Ledger\n", result)
397
+ self.assertIn("```math\n \\text{Ledger} = \\text{Credit}\n ```", result)
398
+ self.assertIn("- Next item", result)
399
+
400
+ def test_math_macro_inside_list_item_survives_round_trip(self) -> None:
401
+ from conjira_cli.markdown_import import markdown_to_storage_html
402
+
403
+ html = (
404
+ "<ul><li>Ledger"
405
+ '<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
406
+ "<ac:plain-text-body><![CDATA[\\text{Ledger} = \\text{Credit}]]></ac:plain-text-body>"
407
+ "</ac:structured-macro>"
408
+ "</li><li>Next item</li></ul>"
409
+ )
410
+
411
+ round_tripped = markdown_to_storage_html(self.exporter.convert_fragment(html))
412
+
413
+ self.assertIn(
414
+ '<li>Ledger<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
415
+ "<ac:plain-text-body><![CDATA[\\text{Ledger} = \\text{Credit}]]></ac:plain-text-body>"
416
+ "</ac:structured-macro></li>",
417
+ round_tripped,
418
+ )
419
+ self.assertIn("<li>Next item</li>", round_tripped)
420
+
421
+ def test_plain_text_macro_wrapped_in_paragraph_renders_as_fence(self) -> None:
422
+ html = (
423
+ "<p>Before"
424
+ '<ac:structured-macro ac:name="code" ac:schema-version="1">'
425
+ '<ac:parameter ac:name="language">text</ac:parameter>'
426
+ "<ac:plain-text-body><![CDATA[x = 1]]></ac:plain-text-body>"
427
+ "</ac:structured-macro>"
428
+ "After</p>"
429
+ )
430
+
431
+ result = self.exporter.convert_fragment(html)
432
+
433
+ self.assertIn("Before", result)
434
+ self.assertIn("```text\nx = 1\n```", result)
435
+ self.assertIn("After", result)
@@ -298,3 +298,31 @@ class MarkdownImportTests(unittest.TestCase):
298
298
  # Whole cell goes through as raw HTML; trailing markdown is NOT rendered.
299
299
  self.assertIn("<ul><li>x</li></ul> trailing [link](http://example.com)", result)
300
300
  self.assertNotIn('<a href="http://example.com">', result)
301
+
302
+
303
+ class MathFenceImportTests(unittest.TestCase):
304
+ def test_math_fence_becomes_mathblock_macro(self) -> None:
305
+ result = markdown_to_storage_html("```math\n\\text{Credit} = a \\times E\n```\n")
306
+
307
+ self.assertIn(
308
+ '<ac:structured-macro ac:name="mathblock" ac:schema-version="1">',
309
+ result,
310
+ )
311
+ self.assertIn(
312
+ "<ac:plain-text-body><![CDATA[\\text{Credit} = a \\times E]]></ac:plain-text-body>",
313
+ result,
314
+ )
315
+ self.assertNotIn('ac:name="code"', result)
316
+
317
+ def test_indented_math_fence_inside_list_item_becomes_nested_mathblock(self) -> None:
318
+ markdown = "- Ledger\n ```math\n x = 1\n ```\n- Next item\n"
319
+
320
+ result = markdown_to_storage_html(markdown)
321
+
322
+ self.assertIn(
323
+ "<li>Ledger<ac:structured-macro ac:name=\"mathblock\" ac:schema-version=\"1\">"
324
+ "<ac:plain-text-body><![CDATA[x = 1]]></ac:plain-text-body></ac:structured-macro></li>",
325
+ result,
326
+ )
327
+ self.assertIn("<li>Next item</li>", result)
328
+ self.assertNotIn("```", result)
@@ -0,0 +1,180 @@
1
+ import unittest
2
+
3
+ from conjira_cli.section_edit import (
4
+ SectionEditError,
5
+ insert_after_heading_html,
6
+ replace_section_html,
7
+ )
8
+
9
+
10
+ class SectionEditTests(unittest.TestCase):
11
+ def test_replace_section_html_replaces_heading_block_until_next_peer_heading(self) -> None:
12
+ body_html = (
13
+ "<h1>Guide</h1>"
14
+ "<p>Intro</p>"
15
+ "<h2>Install</h2>"
16
+ "<p>Old install step</p>"
17
+ "<h3>Nested detail</h3>"
18
+ "<p>Nested note</p>"
19
+ "<h2>Usage</h2>"
20
+ "<p>Run command</p>"
21
+ )
22
+
23
+ result = replace_section_html(
24
+ body_html,
25
+ heading="Install",
26
+ replacement_html="<p>New install step</p><ul><li>Do this first</li></ul>",
27
+ )
28
+
29
+ self.assertEqual(result.matched_heading, "Install")
30
+ self.assertEqual(result.heading_level, 2)
31
+ self.assertIn("<p>Old install step</p>", result.old_section_html)
32
+ self.assertIn("<h3>Nested detail</h3>", result.old_section_html)
33
+ self.assertIn("<p>New install step</p>", result.new_section_html)
34
+ self.assertIn("<ul><li>Do this first</li></ul>", result.updated_body_html)
35
+ self.assertIn("<h2>Usage</h2>", result.updated_body_html)
36
+ self.assertNotIn("Old install step", result.updated_body_html)
37
+
38
+ def test_replace_section_html_fails_when_heading_missing(self) -> None:
39
+ with self.assertRaises(SectionEditError):
40
+ replace_section_html(
41
+ "<h1>Guide</h1><p>Body</p>",
42
+ heading="Install",
43
+ replacement_html="<p>Replacement</p>",
44
+ )
45
+
46
+ def test_replace_section_html_fails_when_heading_ambiguous(self) -> None:
47
+ with self.assertRaises(SectionEditError):
48
+ replace_section_html(
49
+ "<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
50
+ heading="Install",
51
+ replacement_html="<p>Replacement</p>",
52
+ )
53
+
54
+ def test_insert_after_heading_html_inserts_immediately_after_heading(self) -> None:
55
+ body_html = (
56
+ "<h1>Guide</h1>"
57
+ "<p>Intro</p>"
58
+ "<h2>Install</h2>"
59
+ "<p>Old install step</p>"
60
+ "<h2>Usage</h2>"
61
+ "<p>Run command</p>"
62
+ )
63
+
64
+ result = insert_after_heading_html(
65
+ body_html,
66
+ heading="Install",
67
+ inserted_html="<p>New note</p><ul><li>Check this first</li></ul>",
68
+ )
69
+
70
+ self.assertEqual(result.matched_heading, "Install")
71
+ self.assertEqual(result.heading_level, 2)
72
+ self.assertIn("<p>New note</p>", result.inserted_html)
73
+ self.assertIn("<ul><li>Check this first</li></ul>", result.updated_body_html)
74
+ self.assertIn(
75
+ "<h2>Install</h2><p>New note</p><ul><li>Check this first</li></ul><p>Old install step</p>",
76
+ result.updated_body_html,
77
+ )
78
+
79
+ def test_insert_after_heading_html_fails_when_heading_missing(self) -> None:
80
+ with self.assertRaises(SectionEditError):
81
+ insert_after_heading_html(
82
+ "<h1>Guide</h1><p>Body</p>",
83
+ heading="Install",
84
+ inserted_html="<p>Inserted</p>",
85
+ )
86
+
87
+ def test_insert_after_heading_html_fails_when_heading_ambiguous(self) -> None:
88
+ with self.assertRaises(SectionEditError):
89
+ insert_after_heading_html(
90
+ "<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
91
+ heading="Install",
92
+ inserted_html="<p>Inserted</p>",
93
+ )
94
+
95
+
96
+ _CODE_MACRO = (
97
+ '<ac:structured-macro ac:name="code" ac:schema-version="1">'
98
+ '<ac:parameter ac:name="language">text</ac:parameter>'
99
+ "<ac:plain-text-body><![CDATA[Credit = Cost × E]]></ac:plain-text-body>"
100
+ "</ac:structured-macro>"
101
+ )
102
+
103
+
104
+ class SectionEditCdataTests(unittest.TestCase):
105
+ def test_replace_section_keeps_cdata_macro_body_outside_edited_section(self) -> None:
106
+ body_html = (
107
+ "<h2>Formula</h2>"
108
+ + _CODE_MACRO
109
+ + "<h2>Other</h2><p>old</p>"
110
+ )
111
+
112
+ result = replace_section_html(
113
+ body_html,
114
+ heading="Other",
115
+ replacement_html="<p>new</p>",
116
+ )
117
+
118
+ self.assertIn("<![CDATA[Credit = Cost × E]]>", result.updated_body_html)
119
+ self.assertIn("<p>new</p>", result.updated_body_html)
120
+ self.assertNotIn("<p>old</p>", result.updated_body_html)
121
+
122
+ def test_replace_section_keeps_cdata_in_replacement_and_in_replaced_section(self) -> None:
123
+ old_macro = _CODE_MACRO.replace("Credit = Cost × E", "old formula")
124
+ body_html = "<h2>Formula</h2>" + old_macro + "<h2>Other</h2><p>text</p>"
125
+
126
+ result = replace_section_html(
127
+ body_html,
128
+ heading="Formula",
129
+ replacement_html=_CODE_MACRO + "<p>note</p>",
130
+ )
131
+
132
+ self.assertIn("<![CDATA[old formula]]>", result.old_section_html)
133
+ self.assertIn("<![CDATA[Credit = Cost × E]]>", result.new_section_html)
134
+ self.assertIn("<![CDATA[Credit = Cost × E]]>", result.updated_body_html)
135
+ self.assertNotIn("old formula", result.updated_body_html)
136
+
137
+ def test_insert_after_heading_keeps_cdata_in_body_and_inserted_fragment(self) -> None:
138
+ existing_macro = _CODE_MACRO.replace("Credit = Cost × E", "existing formula")
139
+ body_html = "<h2>Formula</h2>" + existing_macro + "<h2>Other</h2><p>text</p>"
140
+
141
+ result = insert_after_heading_html(
142
+ body_html,
143
+ heading="Other",
144
+ inserted_html=_CODE_MACRO,
145
+ )
146
+
147
+ self.assertIn("<![CDATA[Credit = Cost × E]]>", result.inserted_html)
148
+ self.assertIn("<![CDATA[existing formula]]>", result.updated_body_html)
149
+ self.assertIn("<![CDATA[Credit = Cost × E]]>", result.updated_body_html)
150
+
151
+ def test_multiple_cdata_sections_with_special_characters_are_restored_in_place(self) -> None:
152
+ math_macro = (
153
+ '<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
154
+ "<ac:plain-text-body><![CDATA[\\text{Credit} = \\sum_{i} a_i < b & c]]></ac:plain-text-body>"
155
+ "</ac:structured-macro>"
156
+ )
157
+ second_code = _CODE_MACRO.replace("Credit = Cost × E", "if (a < b && c) { run(); }")
158
+ body_html = (
159
+ "<h2>Formula</h2>" + _CODE_MACRO + math_macro
160
+ + "<h2>Code</h2>" + second_code
161
+ + "<h2>Other</h2><p>old</p>"
162
+ )
163
+
164
+ result = replace_section_html(
165
+ body_html,
166
+ heading="Other",
167
+ replacement_html="<p>new</p>",
168
+ )
169
+
170
+ updated = result.updated_body_html
171
+ expected_in_order = [
172
+ "<![CDATA[Credit = Cost × E]]>",
173
+ "<![CDATA[\\text{Credit} = \\sum_{i} a_i < b & c]]>",
174
+ "<![CDATA[if (a < b && c) { run(); }]]>",
175
+ "<h2>Other</h2><p>new</p>",
176
+ ]
177
+ positions = [updated.find(piece) for piece in expected_in_order]
178
+ self.assertTrue(all(position >= 0 for position in positions), updated)
179
+ self.assertEqual(positions, sorted(positions), updated)
180
+ self.assertNotIn("conjira-cdata", updated)
@@ -1,93 +0,0 @@
1
- import unittest
2
-
3
- from conjira_cli.section_edit import (
4
- SectionEditError,
5
- insert_after_heading_html,
6
- replace_section_html,
7
- )
8
-
9
-
10
- class SectionEditTests(unittest.TestCase):
11
- def test_replace_section_html_replaces_heading_block_until_next_peer_heading(self) -> None:
12
- body_html = (
13
- "<h1>Guide</h1>"
14
- "<p>Intro</p>"
15
- "<h2>Install</h2>"
16
- "<p>Old install step</p>"
17
- "<h3>Nested detail</h3>"
18
- "<p>Nested note</p>"
19
- "<h2>Usage</h2>"
20
- "<p>Run command</p>"
21
- )
22
-
23
- result = replace_section_html(
24
- body_html,
25
- heading="Install",
26
- replacement_html="<p>New install step</p><ul><li>Do this first</li></ul>",
27
- )
28
-
29
- self.assertEqual(result.matched_heading, "Install")
30
- self.assertEqual(result.heading_level, 2)
31
- self.assertIn("<p>Old install step</p>", result.old_section_html)
32
- self.assertIn("<h3>Nested detail</h3>", result.old_section_html)
33
- self.assertIn("<p>New install step</p>", result.new_section_html)
34
- self.assertIn("<ul><li>Do this first</li></ul>", result.updated_body_html)
35
- self.assertIn("<h2>Usage</h2>", result.updated_body_html)
36
- self.assertNotIn("Old install step", result.updated_body_html)
37
-
38
- def test_replace_section_html_fails_when_heading_missing(self) -> None:
39
- with self.assertRaises(SectionEditError):
40
- replace_section_html(
41
- "<h1>Guide</h1><p>Body</p>",
42
- heading="Install",
43
- replacement_html="<p>Replacement</p>",
44
- )
45
-
46
- def test_replace_section_html_fails_when_heading_ambiguous(self) -> None:
47
- with self.assertRaises(SectionEditError):
48
- replace_section_html(
49
- "<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
50
- heading="Install",
51
- replacement_html="<p>Replacement</p>",
52
- )
53
-
54
- def test_insert_after_heading_html_inserts_immediately_after_heading(self) -> None:
55
- body_html = (
56
- "<h1>Guide</h1>"
57
- "<p>Intro</p>"
58
- "<h2>Install</h2>"
59
- "<p>Old install step</p>"
60
- "<h2>Usage</h2>"
61
- "<p>Run command</p>"
62
- )
63
-
64
- result = insert_after_heading_html(
65
- body_html,
66
- heading="Install",
67
- inserted_html="<p>New note</p><ul><li>Check this first</li></ul>",
68
- )
69
-
70
- self.assertEqual(result.matched_heading, "Install")
71
- self.assertEqual(result.heading_level, 2)
72
- self.assertIn("<p>New note</p>", result.inserted_html)
73
- self.assertIn("<ul><li>Check this first</li></ul>", result.updated_body_html)
74
- self.assertIn(
75
- "<h2>Install</h2><p>New note</p><ul><li>Check this first</li></ul><p>Old install step</p>",
76
- result.updated_body_html,
77
- )
78
-
79
- def test_insert_after_heading_html_fails_when_heading_missing(self) -> None:
80
- with self.assertRaises(SectionEditError):
81
- insert_after_heading_html(
82
- "<h1>Guide</h1><p>Body</p>",
83
- heading="Install",
84
- inserted_html="<p>Inserted</p>",
85
- )
86
-
87
- def test_insert_after_heading_html_fails_when_heading_ambiguous(self) -> None:
88
- with self.assertRaises(SectionEditError):
89
- insert_after_heading_html(
90
- "<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
91
- heading="Install",
92
- inserted_html="<p>Inserted</p>",
93
- )
File without changes
File without changes
File without changes