confpub-cli 1.7.0__tar.gz → 1.7.2__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.
- confpub_cli-1.7.2/.github/copilot-instructions.md +88 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/PKG-INFO +1 -1
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/__init__.py +1 -1
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/converter.py +39 -1
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/guide.py +3 -1
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/reverse_converter.py +15 -7
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_converter.py +74 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_reverse_converter.py +44 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/.gitignore +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/CLAUDE.md +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/LICENSE +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/PRD.md +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/README.md +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/applier.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/assets.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/cli.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/config.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/confluence.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/envelope.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/errors.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/front_matter.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/lockfile.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/macro_plugin.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/manifest.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/output.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/planner.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/publish.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/puller.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/py.typed +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/validator.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub/verifier.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/confpub.lock +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/pyproject.toml +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/__init__.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/conftest.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_applier.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_assets.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_config.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_confluence.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_envelope.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_errors.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_front_matter.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_guide.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_integration.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_macro_plugin.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_manifest.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_output.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_planner.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_publish.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_puller.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_validator.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/tests/test_verifier.py +0 -0
- {confpub_cli-1.7.0 → confpub_cli-1.7.2}/uv.lock +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copilot Instructions
|
|
2
|
+
|
|
3
|
+
Agent-first CLI for publishing Markdown to Confluence. Python 3.10+, no linter configured.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv pip install -e ".[dev]" # Install with dev deps
|
|
9
|
+
pytest tests/ -v # Run all tests
|
|
10
|
+
pytest tests/test_converter.py -v # Run one module
|
|
11
|
+
pytest tests/test_converter.py::TestHeadings::test_h1 -v # Run single test
|
|
12
|
+
pytest tests/ -k "fingerprint" -v # Run by name pattern
|
|
13
|
+
pytest tests/ -v --cov=confpub # Run with coverage
|
|
14
|
+
uvx hatch version minor # Bump version (patch/minor/major)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
### Command flow
|
|
20
|
+
|
|
21
|
+
Every command follows this path through `cli.py`:
|
|
22
|
+
|
|
23
|
+
1. Typer handler receives flags and calls `command_context(command_name)` — a context manager that records timing, catches errors, and emits the final JSON envelope on stdout.
|
|
24
|
+
2. Handler populates a `CommandResult` (result, target, warnings, metrics) then delegates to a domain module (`publish.py`, `applier.py`, `puller.py`, `planner.py`, etc.).
|
|
25
|
+
3. Domain module calls `ConfluenceClient` (`confluence.py`), which wraps `atlassian-python-api` and translates its exceptions into `ConfpubError`.
|
|
26
|
+
4. `command_context` serializes an `Envelope` and writes it to stdout; any exception that escapes domain code is caught here too.
|
|
27
|
+
|
|
28
|
+
### Subcommand groups
|
|
29
|
+
|
|
30
|
+
`page`, `plan`, `auth`, `config`, `space`, `attachment`, `label`, `comment` — each is a `typer.Typer` added to the root `app` in `cli.py`.
|
|
31
|
+
|
|
32
|
+
### Transactional plan workflow
|
|
33
|
+
|
|
34
|
+
`plan create` → `plan validate` → `plan apply` → `plan verify`
|
|
35
|
+
|
|
36
|
+
Only `plan apply` has side effects. `plan create` fingerprints existing pages (SHA-256 of storage-format body) so that `plan apply` can detect external edits (`ERR_CONFLICT_FINGERPRINT`) before writing.
|
|
37
|
+
|
|
38
|
+
### Markdown conversion
|
|
39
|
+
|
|
40
|
+
- `converter.py` — `markdown-it-py` + custom `ConfluenceRenderer` → Confluence Storage Format XHTML. Pure function, no I/O.
|
|
41
|
+
- `reverse_converter.py` — BeautifulSoup4 + markdownify → Markdown. Pure function, no I/O.
|
|
42
|
+
|
|
43
|
+
### Lockfile
|
|
44
|
+
|
|
45
|
+
`confpub.lock` maps page titles → `{page_id, version, content_fingerprint}`. Updated atomically (tempfile + `os.replace`) by publish, pull, apply, and delete.
|
|
46
|
+
|
|
47
|
+
## Key Conventions
|
|
48
|
+
|
|
49
|
+
### stdout is JSON-only
|
|
50
|
+
|
|
51
|
+
Every invocation emits exactly one `Envelope` object on stdout. Never write anything else there. Progress events and diagnostics go to stderr via `output.py` helpers (`emit_progress`, `emit_stderr`), and are suppressed when `--quiet`, `LLM=true`, or stdout is not a TTY.
|
|
52
|
+
|
|
53
|
+
### Error codes are stable public API
|
|
54
|
+
|
|
55
|
+
Error codes use the pattern `ERR_{CATEGORY}_{SPECIFIC}`. Category prefixes map to fixed exit codes:
|
|
56
|
+
|
|
57
|
+
| Prefix | Exit code |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `ERR_VALIDATION` | 10 |
|
|
60
|
+
| `ERR_AUTH` | 20 |
|
|
61
|
+
| `ERR_CONFLICT` | 40 |
|
|
62
|
+
| `ERR_IO` | 50 |
|
|
63
|
+
| `ERR_INTERNAL` | 90 |
|
|
64
|
+
|
|
65
|
+
Never rename or remove an error code constant without a major version bump. Use the builder helpers (`validation_error(...)`, `auth_error(...)`, etc.) in `errors.py` rather than constructing `ConfpubError` directly.
|
|
66
|
+
|
|
67
|
+
### ConfpubError
|
|
68
|
+
|
|
69
|
+
All domain errors must be `ConfpubError` instances. Fields: `code`, `message`, `retryable`, `suggested_action`, `details`. These flow directly into `Envelope.errors`.
|
|
70
|
+
|
|
71
|
+
### Pydantic models everywhere
|
|
72
|
+
|
|
73
|
+
`Envelope`, `Lockfile`, `Manifest`, `ConfigModel`, and plan artifacts are all Pydantic v2 `BaseModel`. Use `model_dump(mode="json")` for serialization.
|
|
74
|
+
|
|
75
|
+
### Credential precedence
|
|
76
|
+
|
|
77
|
+
CLI flags → env vars (`CONFPUB_URL`, `CONFPUB_TOKEN`, `CONFPUB_USER`, `CONFPUB_SPACE`, `CONFPUB_SSL_VERIFY`) → config file (`~/.config/confpub/config.json`) → OS keychain.
|
|
78
|
+
|
|
79
|
+
### Test conventions
|
|
80
|
+
|
|
81
|
+
- Use the `run_cli` fixture from `conftest.py` (wraps `typer.testing.CliRunner`) for all CLI-level tests.
|
|
82
|
+
- Mock all Confluence API calls with `unittest.mock` — no live integration tests.
|
|
83
|
+
- Group related test cases in classes (e.g. `class TestHeadings`, `class TestApplyPlanReal`).
|
|
84
|
+
- Unit tests for new domain functions go in the matching `test_<module>.py`; CLI-level behavior goes in `test_integration.py`.
|
|
85
|
+
|
|
86
|
+
## Version
|
|
87
|
+
|
|
88
|
+
Defined in `confpub/__init__.py`. Hatch reads it from `pyproject.toml`. Pushing to `main` triggers the GitHub Actions publish workflow.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 1.7.
|
|
3
|
+
Version: 1.7.2
|
|
4
4
|
Summary: Agent-first CLI to publish Markdown to Confluence
|
|
5
5
|
Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
|
|
@@ -713,6 +713,43 @@ def _create_parser() -> MarkdownIt:
|
|
|
713
713
|
return md
|
|
714
714
|
|
|
715
715
|
|
|
716
|
+
_LAYOUT_BLOCK_RE = re.compile(r"<ac:layout>.*?</ac:layout>", re.DOTALL)
|
|
717
|
+
|
|
718
|
+
_LAYOUT_WRAP_PREFIX = (
|
|
719
|
+
'<ac:layout><ac:layout-section ac:type="single"><ac:layout-cell>'
|
|
720
|
+
)
|
|
721
|
+
_LAYOUT_WRAP_SUFFIX = "</ac:layout-cell></ac:layout-section></ac:layout>"
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _wrap_non_layout_content(html: str) -> str:
|
|
725
|
+
"""Wrap content outside ``<ac:layout>`` blocks in single-column layouts.
|
|
726
|
+
|
|
727
|
+
Confluence Server's editor cannot handle a mix of layout blocks and
|
|
728
|
+
top-level content on the same page — it enters "layout mode" and makes
|
|
729
|
+
everything outside cells read-only. This function detects when layout
|
|
730
|
+
blocks are present and wraps any surrounding content in its own
|
|
731
|
+
single-column layout so the entire page is in layout mode.
|
|
732
|
+
"""
|
|
733
|
+
if "<ac:layout>" not in html:
|
|
734
|
+
return html
|
|
735
|
+
|
|
736
|
+
parts: list[str] = []
|
|
737
|
+
last_end = 0
|
|
738
|
+
|
|
739
|
+
for match in _LAYOUT_BLOCK_RE.finditer(html):
|
|
740
|
+
before = html[last_end:match.start()]
|
|
741
|
+
if before.strip():
|
|
742
|
+
parts.append(_LAYOUT_WRAP_PREFIX + before + _LAYOUT_WRAP_SUFFIX)
|
|
743
|
+
parts.append(match.group(0))
|
|
744
|
+
last_end = match.end()
|
|
745
|
+
|
|
746
|
+
after = html[last_end:]
|
|
747
|
+
if after.strip():
|
|
748
|
+
parts.append(_LAYOUT_WRAP_PREFIX + after + _LAYOUT_WRAP_SUFFIX)
|
|
749
|
+
|
|
750
|
+
return "".join(parts)
|
|
751
|
+
|
|
752
|
+
|
|
716
753
|
def convert_markdown(md_text: str) -> str:
|
|
717
754
|
"""Convert Markdown text to Confluence Storage Format.
|
|
718
755
|
|
|
@@ -725,7 +762,8 @@ def convert_markdown(md_text: str) -> str:
|
|
|
725
762
|
parser = _create_parser()
|
|
726
763
|
tokens = parser.parse(md_text)
|
|
727
764
|
renderer = ConfluenceRenderer()
|
|
728
|
-
|
|
765
|
+
html = renderer.render(tokens, {}, {})
|
|
766
|
+
return _wrap_non_layout_content(html)
|
|
729
767
|
|
|
730
768
|
|
|
731
769
|
def extract_h1_title(md_text: str) -> str | None:
|
|
@@ -409,7 +409,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
409
409
|
),
|
|
410
410
|
"panels": "::: panel Title\\ncontent\\n::: → ac:structured-macro panel",
|
|
411
411
|
"expand": "::: expand Title\\ncontent\\n::: → ac:structured-macro expand",
|
|
412
|
-
"layouts": ":::: layout two-equal\\n::: cell\\n...\\n::::\\n → ac:layout with ac:layout-section",
|
|
412
|
+
"layouts": ":::: layout two-equal\\n::: cell\\n...\\n::::\\n → ac:layout with ac:layout-section. Content outside layout blocks is auto-wrapped in a single-column layout (Confluence requires all content in layout cells when layouts are used).",
|
|
413
413
|
"status": "{status:Title|colour=Color} → ac:structured-macro status",
|
|
414
414
|
"toc": "{toc} / {toc:maxLevel=N} → ac:structured-macro toc",
|
|
415
415
|
"anchor": "{anchor:name} → ac:structured-macro anchor",
|
|
@@ -425,6 +425,8 @@ def build_guide() -> dict[str, Any]:
|
|
|
425
425
|
"All features are always-on — the parser simply ignores syntax that isn't used. "
|
|
426
426
|
"Math macros require the Confluence LaTeX Math plugin to be installed on the server. "
|
|
427
427
|
"Layouts use :::: (4 colons) for the outer layout block and ::: (3 colons) for inner cells. "
|
|
428
|
+
"When layouts are used, ALL page content must live inside layout cells — "
|
|
429
|
+
"confpub auto-wraps any content outside layout blocks in a single-column layout to satisfy this Confluence requirement. "
|
|
428
430
|
"Use {macro-name:params} for body-less Confluence macros. "
|
|
429
431
|
"Macros on their own line become block-level (no <p> wrapping)."
|
|
430
432
|
),
|
|
@@ -452,14 +452,18 @@ def _preprocess_storage_format(html: str) -> tuple[BeautifulSoup, list[str]]:
|
|
|
452
452
|
if next_sib and next_sib.name == "ol":
|
|
453
453
|
hr.decompose()
|
|
454
454
|
|
|
455
|
-
# 5. Transform ac:layout → div[data-layout-macro]
|
|
455
|
+
# 5. Transform ac:layout → div[data-layout-macro] per section
|
|
456
456
|
for layout in soup.find_all("ac:layout"):
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
457
|
+
sections = layout.find_all("ac:layout-section", recursive=False)
|
|
458
|
+
if not sections:
|
|
459
|
+
layout.decompose()
|
|
460
|
+
continue
|
|
461
|
+
# Build one layout div per section, then replace the ac:layout
|
|
462
|
+
section_divs = []
|
|
463
|
+
for section in sections:
|
|
464
|
+
layout_div = soup.new_tag("div")
|
|
465
|
+
layout_div["data-layout-macro"] = "layout"
|
|
461
466
|
layout_type = section.get("ac:type", "single")
|
|
462
|
-
# Convert underscores back to hyphens
|
|
463
467
|
layout_type = layout_type.replace("_", "-")
|
|
464
468
|
layout_div["data-layout-type"] = layout_type
|
|
465
469
|
for cell in section.find_all("ac:layout-cell", recursive=False):
|
|
@@ -468,7 +472,11 @@ def _preprocess_storage_format(html: str) -> tuple[BeautifulSoup, list[str]]:
|
|
|
468
472
|
for child in list(cell.children):
|
|
469
473
|
cell_div.append(child.extract())
|
|
470
474
|
layout_div.append(cell_div)
|
|
471
|
-
|
|
475
|
+
section_divs.append(layout_div)
|
|
476
|
+
# Insert all section divs after the layout, then remove it
|
|
477
|
+
for div in reversed(section_divs):
|
|
478
|
+
layout.insert_after(div)
|
|
479
|
+
layout.decompose()
|
|
472
480
|
|
|
473
481
|
# 6. Transform ac:link → a
|
|
474
482
|
for link in soup.find_all("ac:link"):
|
|
@@ -421,6 +421,80 @@ class TestContainerLayout:
|
|
|
421
421
|
assert result.count("<ac:layout-cell>") == 3
|
|
422
422
|
|
|
423
423
|
|
|
424
|
+
class TestLayoutWrapping:
|
|
425
|
+
"""Confluence Server cannot handle content outside <ac:layout> blocks.
|
|
426
|
+
|
|
427
|
+
When layout blocks are present, all surrounding content must be wrapped
|
|
428
|
+
in single-column layout blocks so the editor stays in layout mode.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
def test_content_before_layout_is_wrapped(self):
|
|
432
|
+
md = "## Heading\n\nParagraph.\n\n:::: layout two-equal\n::: cell\nLeft\n:::\n::: cell\nRight\n:::\n::::"
|
|
433
|
+
result = convert_markdown(md)
|
|
434
|
+
# The heading and paragraph should be inside a single-column layout
|
|
435
|
+
assert result.startswith('<ac:layout><ac:layout-section ac:type="single"><ac:layout-cell>')
|
|
436
|
+
# The original layout block should still be present
|
|
437
|
+
assert '<ac:layout><ac:layout-section ac:type="two_equal">' in result
|
|
438
|
+
|
|
439
|
+
def test_content_after_layout_is_wrapped(self):
|
|
440
|
+
md = ":::: layout two-equal\n::: cell\nLeft\n:::\n::: cell\nRight\n:::\n::::\n\n## After\n\nMore text."
|
|
441
|
+
result = convert_markdown(md)
|
|
442
|
+
# Should end with a wrapped single-column block
|
|
443
|
+
assert result.endswith("</ac:layout-cell></ac:layout-section></ac:layout>")
|
|
444
|
+
# Count: one two_equal layout + one single wrapper
|
|
445
|
+
assert result.count("<ac:layout>") == 2
|
|
446
|
+
|
|
447
|
+
def test_content_before_and_after_layout_wrapped(self):
|
|
448
|
+
md = (
|
|
449
|
+
"## Before\n\n"
|
|
450
|
+
":::: layout two-equal\n::: cell\nL\n:::\n::: cell\nR\n:::\n::::\n\n"
|
|
451
|
+
"## After\n"
|
|
452
|
+
)
|
|
453
|
+
result = convert_markdown(md)
|
|
454
|
+
# Two wrappers (before + after) plus the real layout = 3 layout blocks
|
|
455
|
+
assert result.count("<ac:layout>") == 3
|
|
456
|
+
assert result.count("</ac:layout>") == 3
|
|
457
|
+
|
|
458
|
+
def test_no_layout_blocks_unchanged(self):
|
|
459
|
+
md = "## Heading\n\nParagraph."
|
|
460
|
+
result = convert_markdown(md)
|
|
461
|
+
# No layout blocks should be introduced
|
|
462
|
+
assert "<ac:layout>" not in result
|
|
463
|
+
|
|
464
|
+
def test_only_layout_no_extra_wrapping(self):
|
|
465
|
+
md = ":::: layout two-equal\n::: cell\nLeft\n:::\n::: cell\nRight\n:::\n::::"
|
|
466
|
+
result = convert_markdown(md)
|
|
467
|
+
# Only one layout block — no extra wrappers
|
|
468
|
+
assert result.count("<ac:layout>") == 1
|
|
469
|
+
|
|
470
|
+
def test_mixed_content_with_table_and_macros(self):
|
|
471
|
+
"""Reproduce the exact scenario from the bug report."""
|
|
472
|
+
md = (
|
|
473
|
+
"## Some heading\n\n"
|
|
474
|
+
"Regular paragraph text.\n\n"
|
|
475
|
+
":::: layout two-equal\n"
|
|
476
|
+
"::: cell\nLeft column content.\n:::\n"
|
|
477
|
+
"::: cell\nRight column content.\n:::\n"
|
|
478
|
+
"::::\n\n"
|
|
479
|
+
"## Another heading\n\n"
|
|
480
|
+
"| Col A | Col B |\n"
|
|
481
|
+
"|-------|-------|\n"
|
|
482
|
+
"| 1 | 2 |\n"
|
|
483
|
+
)
|
|
484
|
+
result = convert_markdown(md)
|
|
485
|
+
# All content should be inside layout blocks
|
|
486
|
+
assert result.count("<ac:layout>") == 3
|
|
487
|
+
# The heading before should be wrapped
|
|
488
|
+
assert "Some heading" in result
|
|
489
|
+
# The table after should be wrapped
|
|
490
|
+
assert "Col A" in result
|
|
491
|
+
# No top-level content outside layout blocks
|
|
492
|
+
# Split by </ac:layout> and check there's nothing outside
|
|
493
|
+
import re
|
|
494
|
+
outside = re.sub(r"<ac:layout>.*?</ac:layout>", "", result, flags=re.DOTALL)
|
|
495
|
+
assert outside.strip() == ""
|
|
496
|
+
|
|
497
|
+
|
|
424
498
|
class TestExtractFrontMatter:
|
|
425
499
|
def test_basic_extraction(self):
|
|
426
500
|
md = "---\ntitle: Hello\nspace: DEV\n---\n\n# Content"
|
|
@@ -355,6 +355,50 @@ class TestLayoutReverse:
|
|
|
355
355
|
assert "Right" in result.markdown
|
|
356
356
|
assert "::::" in result.markdown
|
|
357
357
|
|
|
358
|
+
def test_layout_multiple_sections(self):
|
|
359
|
+
"""Multiple ac:layout-section elements must all be converted (bug fix)."""
|
|
360
|
+
html = (
|
|
361
|
+
'<ac:layout>'
|
|
362
|
+
'<ac:layout-section ac:type="single">'
|
|
363
|
+
'<ac:layout-cell><p>Section one</p></ac:layout-cell>'
|
|
364
|
+
'</ac:layout-section>'
|
|
365
|
+
'<ac:layout-section ac:type="single">'
|
|
366
|
+
'<ac:layout-cell>'
|
|
367
|
+
'<h1>Main heading</h1>'
|
|
368
|
+
'<p>Body paragraph with <strong>bold</strong> text.</p>'
|
|
369
|
+
'<ul><li>Item A</li><li>Item B</li></ul>'
|
|
370
|
+
'</ac:layout-cell>'
|
|
371
|
+
'</ac:layout-section>'
|
|
372
|
+
'</ac:layout>'
|
|
373
|
+
)
|
|
374
|
+
result = convert_storage_to_markdown(html)
|
|
375
|
+
assert "Section one" in result.markdown
|
|
376
|
+
assert "# Main heading" in result.markdown
|
|
377
|
+
assert "Body paragraph" in result.markdown
|
|
378
|
+
assert "**bold**" in result.markdown
|
|
379
|
+
assert "Item A" in result.markdown
|
|
380
|
+
assert "Item B" in result.markdown
|
|
381
|
+
|
|
382
|
+
def test_layout_mixed_section_types(self):
|
|
383
|
+
"""Sections with different layout types preserve their types."""
|
|
384
|
+
html = (
|
|
385
|
+
'<ac:layout>'
|
|
386
|
+
'<ac:layout-section ac:type="single">'
|
|
387
|
+
'<ac:layout-cell><p>Full width</p></ac:layout-cell>'
|
|
388
|
+
'</ac:layout-section>'
|
|
389
|
+
'<ac:layout-section ac:type="two_left_sidebar">'
|
|
390
|
+
'<ac:layout-cell><p>Sidebar</p></ac:layout-cell>'
|
|
391
|
+
'<ac:layout-cell><p>Main content</p></ac:layout-cell>'
|
|
392
|
+
'</ac:layout-section>'
|
|
393
|
+
'</ac:layout>'
|
|
394
|
+
)
|
|
395
|
+
result = convert_storage_to_markdown(html)
|
|
396
|
+
assert ":::: layout single" in result.markdown
|
|
397
|
+
assert ":::: layout two-left-sidebar" in result.markdown
|
|
398
|
+
assert "Full width" in result.markdown
|
|
399
|
+
assert "Sidebar" in result.markdown
|
|
400
|
+
assert "Main content" in result.markdown
|
|
401
|
+
|
|
358
402
|
|
|
359
403
|
class TestPluginRoundTrips:
|
|
360
404
|
"""Test round-trip conversion for new plugin features."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|