mkdocs2confluence 0.6.7__tar.gz → 0.6.8__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 (66) hide show
  1. {mkdocs2confluence-0.6.7/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.6.8}/PKG-INFO +3 -11
  2. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/README.md +2 -10
  3. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/pyproject.toml +1 -1
  4. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8/src/mkdocs2confluence.egg-info}/PKG-INFO +3 -11
  5. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/emitter/xhtml.py +48 -0
  6. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/ir/__init__.py +2 -0
  7. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/ir/nodes.py +19 -0
  8. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/parser/markdown.py +68 -0
  9. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preprocess/includes.py +26 -0
  10. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_emitter.py +85 -0
  11. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_ir.py +26 -0
  12. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_parser.py +59 -0
  13. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/LICENSE +0 -0
  14. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/setup.cfg +0 -0
  15. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  16. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  17. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  18. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  19. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  20. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/__init__.py +0 -0
  21. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/cli.py +0 -0
  22. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  23. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/ir/document.py +0 -0
  24. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  25. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  26. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/loader/config.py +0 -0
  27. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  28. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  29. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/loader/page.py +0 -0
  30. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  31. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  32. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  33. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  34. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  35. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  36. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  37. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  38. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/preview/render.py +0 -0
  39. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  40. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  41. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  42. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  43. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  44. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  45. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  46. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  47. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  48. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  49. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_abbrevs.py +0 -0
  50. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_cli.py +0 -0
  51. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_editlink.py +0 -0
  52. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_extra_css.py +0 -0
  53. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_frontmatter.py +0 -0
  54. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_icons.py +0 -0
  55. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_images.py +0 -0
  56. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_internallinks.py +0 -0
  57. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_linkdefs.py +0 -0
  58. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_loader.py +0 -0
  59. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_mermaid.py +0 -0
  60. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_page_loader.py +0 -0
  61. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_preprocess.py +0 -0
  62. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_preview.py +0 -0
  63. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_publish_client.py +0 -0
  64. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_publish_config.py +0 -0
  65. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_publish_pipeline.py +0 -0
  66. {mkdocs2confluence-0.6.7 → mkdocs2confluence-0.6.8}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.6.7
3
+ Version: 0.6.8
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -288,6 +288,7 @@ extra_css:
288
288
  | Internal links `[text](page.md)` | Native Confluence page link; `#fragment` anchors preserved |
289
289
  | `awesome-pages` nav (`.pages` files) | Fully supported |
290
290
  | Edit link banner | `info` macro linking back to source in GitHub/GitLab |
291
+ | Grid cards `<div class="grid cards" markdown>` | Native `ac:layout` multi-column sections (auto-detects 1/2/3 columns from card count); admonitions, paragraphs, and mixed content fully supported inside cards |
291
292
 
292
293
  ### YAML front matter → Page Properties
293
294
 
@@ -325,29 +326,20 @@ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline — C
325
326
 
326
327
  Any unrecognised block is preserved as a visible `warning` macro — no content is silently lost.
327
328
 
328
- `<div class="grid" markdown>` (Material grid cards) has no Confluence equivalent. The wrapper is stripped; inner admonitions render sequentially.
329
-
330
329
  ---
331
330
 
332
331
  ## Known limitations
333
332
 
334
333
  | Feature | Behaviour |
335
334
  |---|---|
336
- | **Admonition styling** | Native macros (`tip`, `info`, `warning`, `note`) use Confluence's fixed theme styling — no custom header/body colours. |
335
+ | **Admonition styling** | `tip`, `info`, `warning`, `note` use Confluence's fixed native macro styling — no custom colours. `danger`, `error`, and `bug` use a custom red `panel` macro with 🚨 prefix. All other types are mapped to the nearest native macro. |
337
336
  | **Abbreviation tooltips** | No native tooltip support. First occurrence expanded inline; remainder left as-is. |
338
337
  | **Grid cards** | Wrapper stripped; inner admonitions rendered individually. |
339
- | **Page width** | Confluence defaults to a narrow fixed-width column. mk2conf publishes with `fullWidth: true` by default (configurable). |
340
338
  | **Page ordering** | Confluence sorts child pages alphabetically. The v2 REST API has no write endpoint for child ordering; nav order cannot be enforced. |
341
339
  | **Code language aliases** | Pygments short aliases (`py`, `js`, `yml`, `ts`, `sh`) are passed through as-is; Confluence requires full language names for syntax highlighting. |
342
340
 
343
341
  ---
344
342
 
345
- ## Roadmap
346
-
347
- - [x] **Delete orphaned pages** — `--prune` detects and removes managed Confluence pages that have been removed from `nav:`. Manually-created pages are never deleted.
348
-
349
- ---
350
-
351
343
  ## Development
352
344
 
353
345
  See [Setup.md](Setup.md) for environment setup.
@@ -250,6 +250,7 @@ extra_css:
250
250
  | Internal links `[text](page.md)` | Native Confluence page link; `#fragment` anchors preserved |
251
251
  | `awesome-pages` nav (`.pages` files) | Fully supported |
252
252
  | Edit link banner | `info` macro linking back to source in GitHub/GitLab |
253
+ | Grid cards `<div class="grid cards" markdown>` | Native `ac:layout` multi-column sections (auto-detects 1/2/3 columns from card count); admonitions, paragraphs, and mixed content fully supported inside cards |
253
254
 
254
255
  ### YAML front matter → Page Properties
255
256
 
@@ -287,29 +288,20 @@ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline — C
287
288
 
288
289
  Any unrecognised block is preserved as a visible `warning` macro — no content is silently lost.
289
290
 
290
- `<div class="grid" markdown>` (Material grid cards) has no Confluence equivalent. The wrapper is stripped; inner admonitions render sequentially.
291
-
292
291
  ---
293
292
 
294
293
  ## Known limitations
295
294
 
296
295
  | Feature | Behaviour |
297
296
  |---|---|
298
- | **Admonition styling** | Native macros (`tip`, `info`, `warning`, `note`) use Confluence's fixed theme styling — no custom header/body colours. |
297
+ | **Admonition styling** | `tip`, `info`, `warning`, `note` use Confluence's fixed native macro styling — no custom colours. `danger`, `error`, and `bug` use a custom red `panel` macro with 🚨 prefix. All other types are mapped to the nearest native macro. |
299
298
  | **Abbreviation tooltips** | No native tooltip support. First occurrence expanded inline; remainder left as-is. |
300
299
  | **Grid cards** | Wrapper stripped; inner admonitions rendered individually. |
301
- | **Page width** | Confluence defaults to a narrow fixed-width column. mk2conf publishes with `fullWidth: true` by default (configurable). |
302
300
  | **Page ordering** | Confluence sorts child pages alphabetically. The v2 REST API has no write endpoint for child ordering; nav order cannot be enforced. |
303
301
  | **Code language aliases** | Pygments short aliases (`py`, `js`, `yml`, `ts`, `sh`) are passed through as-is; Confluence requires full language names for syntax highlighting. |
304
302
 
305
303
  ---
306
304
 
307
- ## Roadmap
308
-
309
- - [x] **Delete orphaned pages** — `--prune` detects and removes managed Confluence pages that have been removed from `nav:`. Manually-created pages are never deleted.
310
-
311
- ---
312
-
313
305
  ## Development
314
306
 
315
307
  See [Setup.md](Setup.md) for environment setup.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.6.7"
3
+ version = "0.6.8"
4
4
  description = "Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more"
5
5
  readme = "README.md"
6
6
  license = { text = "GPL-3.0-or-later" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.6.7
3
+ Version: 0.6.8
4
4
  Summary: Publish MkDocs Material pages to Confluence Cloud — admonitions, Mermaid diagrams, tabs, page properties and more
5
5
  Author: Anders Hybertz
6
6
  License: GPL-3.0-or-later
@@ -288,6 +288,7 @@ extra_css:
288
288
  | Internal links `[text](page.md)` | Native Confluence page link; `#fragment` anchors preserved |
289
289
  | `awesome-pages` nav (`.pages` files) | Fully supported |
290
290
  | Edit link banner | `info` macro linking back to source in GitHub/GitLab |
291
+ | Grid cards `<div class="grid cards" markdown>` | Native `ac:layout` multi-column sections (auto-detects 1/2/3 columns from card count); admonitions, paragraphs, and mixed content fully supported inside cards |
291
292
 
292
293
  ### YAML front matter → Page Properties
293
294
 
@@ -325,29 +326,20 @@ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline — C
325
326
 
326
327
  Any unrecognised block is preserved as a visible `warning` macro — no content is silently lost.
327
328
 
328
- `<div class="grid" markdown>` (Material grid cards) has no Confluence equivalent. The wrapper is stripped; inner admonitions render sequentially.
329
-
330
329
  ---
331
330
 
332
331
  ## Known limitations
333
332
 
334
333
  | Feature | Behaviour |
335
334
  |---|---|
336
- | **Admonition styling** | Native macros (`tip`, `info`, `warning`, `note`) use Confluence's fixed theme styling — no custom header/body colours. |
335
+ | **Admonition styling** | `tip`, `info`, `warning`, `note` use Confluence's fixed native macro styling — no custom colours. `danger`, `error`, and `bug` use a custom red `panel` macro with 🚨 prefix. All other types are mapped to the nearest native macro. |
337
336
  | **Abbreviation tooltips** | No native tooltip support. First occurrence expanded inline; remainder left as-is. |
338
337
  | **Grid cards** | Wrapper stripped; inner admonitions rendered individually. |
339
- | **Page width** | Confluence defaults to a narrow fixed-width column. mk2conf publishes with `fullWidth: true` by default (configurable). |
340
338
  | **Page ordering** | Confluence sorts child pages alphabetically. The v2 REST API has no write endpoint for child ordering; nav order cannot be enforced. |
341
339
  | **Code language aliases** | Pygments short aliases (`py`, `js`, `yml`, `ts`, `sh`) are passed through as-is; Confluence requires full language names for syntax highlighting. |
342
340
 
343
341
  ---
344
342
 
345
- ## Roadmap
346
-
347
- - [x] **Delete orphaned pages** — `--prune` detects and removes managed Confluence pages that have been removed from `nav:`. Manually-created pages are never deleted.
348
-
349
- ---
350
-
351
343
  ## Development
352
344
 
353
345
  See [Setup.md](Setup.md) for environment setup.
@@ -34,6 +34,7 @@ from mkdocs_to_confluence.ir.nodes import (
34
34
  FootnoteBlock,
35
35
  FootnoteRef,
36
36
  FrontMatter,
37
+ GridCards,
37
38
  HorizontalRule,
38
39
  ImageNode,
39
40
  InlineHtmlNode,
@@ -188,6 +189,8 @@ def _emit_node(node: IRNode) -> str:
188
189
  return _emit_front_matter(node)
189
190
  if isinstance(node, FootnoteBlock):
190
191
  return _emit_footnote_block(node)
192
+ if isinstance(node, GridCards):
193
+ return _emit_grid_cards(node)
191
194
  if isinstance(node, UnsupportedBlock):
192
195
  return _emit_unsupported(node)
193
196
  # Inline nodes at block level (shouldn't normally appear, but be safe)
@@ -479,6 +482,51 @@ def _emit_expandable(node: Expandable) -> str:
479
482
  )
480
483
 
481
484
 
485
+ def _grid_layout_type(n: int) -> str:
486
+ """Choose an ``ac:layout-section`` type for *n* grid cards.
487
+
488
+ Rules (minimise empty cells, prefer fewer rows):
489
+ - 1 card → ``single``
490
+ - divisible by 3 → ``three_equal``
491
+ - otherwise → ``two_equal``
492
+ """
493
+ if n == 1:
494
+ return "single"
495
+ if n % 3 == 0:
496
+ return "three_equal"
497
+ return "two_equal"
498
+
499
+
500
+ def _emit_grid_cards(node: GridCards) -> str:
501
+ """Emit a Material grid cards block as a native ``ac:layout`` section.
502
+
503
+ Each card maps to one ``ac:layout-cell``. The column count is auto-detected
504
+ from the number of cards: 1 → single, 3/6/9/… → three_equal, else two_equal.
505
+ Empty padding cells are added when the last row is not full.
506
+ """
507
+ items = node.items
508
+ layout_type = _grid_layout_type(len(items))
509
+ cols = 1 if layout_type == "single" else (3 if layout_type == "three_equal" else 2)
510
+
511
+ rows: list[list[tuple[IRNode, ...]]] = []
512
+ for start in range(0, len(items), cols):
513
+ rows.append(list(items[start : start + cols]))
514
+
515
+ parts: list[str] = ["<ac:layout>\n"]
516
+ for row in rows:
517
+ parts.append(f' <ac:layout-section ac:type="{layout_type}">\n')
518
+ for card_nodes in row:
519
+ parts.append(" <ac:layout-cell>\n")
520
+ parts.append(emit(card_nodes))
521
+ parts.append(" </ac:layout-cell>\n")
522
+ # Pad last row with empty cells if needed.
523
+ for _ in range(cols - len(row)):
524
+ parts.append(" <ac:layout-cell><p /></ac:layout-cell>\n")
525
+ parts.append(" </ac:layout-section>\n")
526
+ parts.append("</ac:layout>\n")
527
+ return "".join(parts)
528
+
529
+
482
530
  def _emit_unsupported(node: UnsupportedBlock) -> str:
483
531
  safe = html.escape(node.raw)
484
532
  return (
@@ -28,6 +28,7 @@ from mkdocs_to_confluence.ir.nodes import (
28
28
  FootnoteBlock,
29
29
  FootnoteDef,
30
30
  FootnoteRef,
31
+ GridCards,
31
32
  HorizontalRule,
32
33
  ImageNode,
33
34
  # Inline HTML nodes
@@ -105,6 +106,7 @@ __all__ = [
105
106
  "ContentTabs",
106
107
  "Tab",
107
108
  "Expandable",
109
+ "GridCards",
108
110
  # Degradation
109
111
  "UnsupportedBlock",
110
112
  # Footnotes
@@ -444,6 +444,21 @@ class FootnoteBlock(IRNode):
444
444
  items: tuple[FootnoteDef, ...]
445
445
 
446
446
 
447
+ # ── Grid cards ───────────────────────────────────────────────────────────────
448
+
449
+
450
+ @dataclass(frozen=True)
451
+ class GridCards(IRNode):
452
+ """Material for MkDocs ``<div class="grid cards" markdown>`` layout.
453
+
454
+ Each element of *items* is a tuple of IR nodes representing one card.
455
+ The emitter maps this to a native ``ac:layout`` multi-column section,
456
+ choosing ``two_equal`` or ``three_equal`` based on the number of cards.
457
+ """
458
+
459
+ items: tuple[tuple[IRNode, ...], ...]
460
+
461
+
447
462
  # ── Graceful degradation ──────────────────────────────────────────────────────
448
463
 
449
464
 
@@ -489,3 +504,7 @@ def walk(node: IRNode) -> Generator[IRNode, None, None]:
489
504
  for item in value:
490
505
  if isinstance(item, IRNode):
491
506
  yield from walk(item)
507
+ elif isinstance(item, tuple):
508
+ for sub in item:
509
+ if isinstance(sub, IRNode):
510
+ yield from walk(sub)
@@ -66,6 +66,7 @@ from mkdocs_to_confluence.ir.nodes import (
66
66
  FootnoteBlock,
67
67
  FootnoteDef,
68
68
  FootnoteRef,
69
+ GridCards,
69
70
  HorizontalRule,
70
71
  ImageNode,
71
72
  InlineHtmlNode,
@@ -201,6 +202,11 @@ class _DefListToken:
201
202
  items: list[_DefListItemData]
202
203
 
203
204
 
205
+ @dataclass
206
+ class _GridCardsToken:
207
+ cards: list[list[_Token]] # each inner list = tokens for one card
208
+
209
+
204
210
  _Token = Union[
205
211
  _HeadingToken,
206
212
  _CodeToken,
@@ -214,6 +220,7 @@ _Token = Union[
214
220
  _TableToken,
215
221
  _FootnoteDefToken,
216
222
  _DefListToken,
223
+ _GridCardsToken,
217
224
  ]
218
225
 
219
226
 
@@ -255,6 +262,41 @@ _FOOTNOTE_REF_RE = re.compile(r'^\[\^(?P<label>[^\]]+)\]')
255
262
  # Matches a definition list definition line: : text
256
263
  _DEFLIST_DEF_RE = re.compile(r'^:\s+(?P<text>.+)$')
257
264
 
265
+ # Matches <div class="grid cards" markdown> (with or without quotes around class value).
266
+ _GRID_CARD_DIV_RE = re.compile(
267
+ r'^\s*<div\b[^>]*\bclass=["\'][^"\']*\bgrid\s+cards\b[^"\']*["\'][^>]*>\s*$',
268
+ re.IGNORECASE,
269
+ )
270
+ _CLOSE_DIV_RE = re.compile(r'^\s*</div>\s*$', re.IGNORECASE)
271
+
272
+
273
+ def _tokenize_grid_cards(inner_lines: list[str]) -> _GridCardsToken:
274
+ """Convert the inner lines of a grid cards div into a ``_GridCardsToken``.
275
+
276
+ Two card formats are supported:
277
+
278
+ * **Bullet list** — each list item is one card::
279
+
280
+ - :icon: **Title** — description text
281
+
282
+ * **Admonition list** — each admonition is one card::
283
+
284
+ !!! tip "Title"
285
+ Body content
286
+ """
287
+ inner_tokens = _tokenize("\n".join(inner_lines))
288
+
289
+ # Bullet list: each item becomes a card (a single paragraph).
290
+ if len(inner_tokens) == 1 and isinstance(inner_tokens[0], _BulletListToken):
291
+ cards: list[list[_Token]] = [
292
+ [_ParagraphToken(lines=[item.text])]
293
+ for item in inner_tokens[0].items
294
+ ]
295
+ return _GridCardsToken(cards=cards)
296
+
297
+ # All other cases: each top-level token is one card.
298
+ return _GridCardsToken(cards=[[tok] for tok in inner_tokens])
299
+
258
300
 
259
301
  def _tokenize(text: str) -> list[_Token]:
260
302
  """Convert *text* into a flat list of tokens.
@@ -268,6 +310,25 @@ def _tokenize(text: str) -> list[_Token]:
268
310
  while i < len(lines):
269
311
  line = lines[i]
270
312
 
313
+ # ── Grid cards div ───────────────────────────────────────────────────
314
+ if _GRID_CARD_DIV_RE.match(line):
315
+ i += 1
316
+ inner_lines: list[str] = []
317
+ depth = 1
318
+ while i < len(lines) and depth > 0:
319
+ if _GRID_CARD_DIV_RE.match(lines[i]):
320
+ depth += 1
321
+ elif _CLOSE_DIV_RE.match(lines[i]):
322
+ depth -= 1
323
+ if depth == 0:
324
+ i += 1
325
+ break
326
+ if depth > 0:
327
+ inner_lines.append(lines[i])
328
+ i += 1
329
+ tokens.append(_tokenize_grid_cards(inner_lines))
330
+ continue
331
+
271
332
  # ── Fenced code block ────────────────────────────────────────────────
272
333
  fence_m = _FENCE_OPEN_RE.match(line)
273
334
  if fence_m:
@@ -1055,6 +1116,13 @@ def _build_tree(
1055
1116
  )
1056
1117
  _append_content(DefinitionList(items=dl_items), stack, root)
1057
1118
 
1119
+ elif isinstance(token, _GridCardsToken):
1120
+ card_ir = tuple(
1121
+ _build_tree(card_tokens, fn_map=_fn)
1122
+ for card_tokens in token.cards
1123
+ )
1124
+ _append_content(GridCards(items=card_ir), stack, root)
1125
+
1058
1126
  # Close all remaining open sections.
1059
1127
  _close_from_level(0, stack, root)
1060
1128
 
@@ -43,6 +43,12 @@ _SNIPPET_RE = re.compile(r'^--8<--\s+"(?P<path>[^"]+)"\s*$')
43
43
 
44
44
  # Raw HTML block wrappers that have no Confluence equivalent — stripped silently.
45
45
  # Matches opening tags like <div class="grid" markdown> and bare </div>.
46
+ # Note: <div class="grid cards" markdown> is NOT stripped here — it is parsed
47
+ # by the tokenizer and converted to a GridCards IR node.
48
+ _GRID_CARD_RE = re.compile(
49
+ r'^\s*<div\b[^>]*\bclass=["\'][^"\']*\bgrid\s+cards\b[^"\']*["\'][^>]*>\s*$',
50
+ re.IGNORECASE,
51
+ )
46
52
  _STRIP_TAG_RE = re.compile(
47
53
  r'^\s*<div\b[^>]*\bmarkdown\b[^>]*>\s*$' # <div ... markdown ...>
48
54
  r'|^\s*</div>\s*$', # </div>
@@ -111,11 +117,16 @@ def strip_unsupported_html(text: str) -> str:
111
117
 
112
118
  Strips ``<div ... markdown ...>`` opening tags and bare ``</div>`` closing
113
119
  tags. The content inside is preserved — only the wrapper lines are dropped.
120
+
121
+ ``<div class="grid cards" markdown>`` blocks are preserved intact — they are
122
+ handled by the parser and converted to native ``ac:layout`` grid sections.
123
+
114
124
  Tags inside fenced code blocks are left untouched.
115
125
  """
116
126
  lines = text.splitlines(keepends=True)
117
127
  result: list[str] = []
118
128
  tracker = FenceTracker()
129
+ grid_depth = 0 # >0 means we are inside a grid cards div
119
130
 
120
131
  for line in lines:
121
132
  stripped = line.rstrip("\n").rstrip("\r")
@@ -125,6 +136,21 @@ def strip_unsupported_html(text: str) -> str:
125
136
  if was_in_fence or tracker.in_fence:
126
137
  result.append(line)
127
138
  continue
139
+
140
+ # Grid card opener — preserve and track nesting depth.
141
+ if _GRID_CARD_RE.match(stripped):
142
+ grid_depth += 1
143
+ result.append(line)
144
+ continue
145
+
146
+ if grid_depth > 0:
147
+ # Inside a grid card block: preserve all content, including </div>.
148
+ if re.match(r'^\s*</div>\s*$', stripped, re.IGNORECASE):
149
+ grid_depth -= 1
150
+ result.append(line)
151
+ continue
152
+
153
+ # Outside grid cards: strip other markdown div wrappers.
128
154
  if _STRIP_TAG_RE.match(stripped):
129
155
  continue # drop the wrapper line silently
130
156
  result.append(line)
@@ -458,3 +458,88 @@ class TestMermaidEmitter:
458
458
  out = emit((node,))
459
459
  assert 'ac:align="center"' in out
460
460
  assert 'ri:filename="diag.png"' in out
461
+
462
+
463
+ class TestGridCardsEmitter:
464
+ def test_two_cards_uses_two_equal(self) -> None:
465
+ from mkdocs_to_confluence.ir.nodes import GridCards, Paragraph, TextNode
466
+ node = GridCards(items=(
467
+ (Paragraph((TextNode("Fast"),)),),
468
+ (Paragraph((TextNode("Secure"),)),),
469
+ ))
470
+ out = emit((node,))
471
+ assert 'ac:type="two_equal"' in out
472
+ assert out.count("<ac:layout-cell>") == 2
473
+ assert "Fast" in out
474
+ assert "Secure" in out
475
+
476
+ def test_three_cards_uses_three_equal(self) -> None:
477
+ from mkdocs_to_confluence.ir.nodes import GridCards, Paragraph, TextNode
478
+ node = GridCards(items=(
479
+ (Paragraph((TextNode("A"),)),),
480
+ (Paragraph((TextNode("B"),)),),
481
+ (Paragraph((TextNode("C"),)),),
482
+ ))
483
+ out = emit((node,))
484
+ assert 'ac:type="three_equal"' in out
485
+ assert out.count("<ac:layout-cell>") == 3
486
+
487
+ def test_one_card_uses_single(self) -> None:
488
+ from mkdocs_to_confluence.ir.nodes import GridCards, Paragraph, TextNode
489
+ node = GridCards(items=(
490
+ (Paragraph((TextNode("Only"),)),),
491
+ ))
492
+ out = emit((node,))
493
+ assert 'ac:type="single"' in out
494
+
495
+ def test_four_cards_uses_two_equal_two_rows(self) -> None:
496
+ from mkdocs_to_confluence.ir.nodes import GridCards, Paragraph, TextNode
497
+ cards = tuple(
498
+ (Paragraph((TextNode(f"Card {i}"),)),) for i in range(4)
499
+ )
500
+ node = GridCards(items=cards)
501
+ out = emit((node,))
502
+ assert 'ac:type="two_equal"' in out
503
+ assert out.count("<ac:layout-section") == 2
504
+ assert out.count("<ac:layout-cell>") == 4
505
+
506
+ def test_six_cards_uses_three_equal_two_rows(self) -> None:
507
+ from mkdocs_to_confluence.ir.nodes import GridCards, Paragraph, TextNode
508
+ cards = tuple(
509
+ (Paragraph((TextNode(f"Card {i}"),)),) for i in range(6)
510
+ )
511
+ node = GridCards(items=cards)
512
+ out = emit((node,))
513
+ assert 'ac:type="three_equal"' in out
514
+ assert out.count("<ac:layout-section") == 2
515
+
516
+ def test_five_cards_pads_last_row(self) -> None:
517
+ from mkdocs_to_confluence.ir.nodes import GridCards, Paragraph, TextNode
518
+ cards = tuple(
519
+ (Paragraph((TextNode(f"Card {i}"),)),) for i in range(5)
520
+ )
521
+ node = GridCards(items=cards)
522
+ out = emit((node,))
523
+ # 5 items with two_equal → 3 rows, last has 1 real + 1 padded cell
524
+ assert 'ac:type="two_equal"' in out
525
+ assert out.count("<ac:layout-cell>") == 6 # 5 real + 1 padding
526
+
527
+ def test_admonition_inside_card(self) -> None:
528
+ from mkdocs_to_confluence.ir.nodes import Admonition, GridCards, Paragraph, TextNode
529
+ adm = Admonition(
530
+ kind="tip",
531
+ title="Speed",
532
+ collapsible=False,
533
+ children=(Paragraph((TextNode("Fast"),)),),
534
+ )
535
+ node = GridCards(items=((adm,), (Paragraph((TextNode("Other"),)),)))
536
+ out = emit((node,))
537
+ assert 'ac:name="tip"' in out
538
+ assert "Speed" in out
539
+
540
+ def test_wraps_in_ac_layout(self) -> None:
541
+ from mkdocs_to_confluence.ir.nodes import GridCards, Paragraph, TextNode
542
+ node = GridCards(items=((Paragraph((TextNode("x"),)),),))
543
+ out = emit((node,))
544
+ assert out.startswith("<ac:layout>")
545
+ assert "</ac:layout>" in out
@@ -17,6 +17,7 @@ from mkdocs_to_confluence.ir import (
17
17
  ContentTabs,
18
18
  Document,
19
19
  Expandable,
20
+ GridCards,
20
21
  HorizontalRule,
21
22
  ImageNode,
22
23
  InsertNode,
@@ -588,3 +589,28 @@ class TestNodeEquality:
588
589
  node = TextNode("key")
589
590
  d = {node: "value"}
590
591
  assert d[TextNode("key")] == "value"
592
+
593
+
594
+ class TestGridCardsNode:
595
+ def test_basic_construction(self) -> None:
596
+ card1 = (TextNode("Fast"),)
597
+ card2 = (TextNode("Secure"),)
598
+ node = GridCards(items=(card1, card2))
599
+ assert len(node.items) == 2
600
+ assert node.items[0][0] == TextNode("Fast")
601
+
602
+ def test_immutable(self) -> None:
603
+ node = GridCards(items=((TextNode("x"),),))
604
+ with pytest.raises(dataclasses.FrozenInstanceError):
605
+ node.items = () # type: ignore[misc]
606
+
607
+ def test_equality(self) -> None:
608
+ a = GridCards(items=((TextNode("x"),),))
609
+ b = GridCards(items=((TextNode("x"),),))
610
+ assert a == b
611
+
612
+ def test_walk_yields_children(self) -> None:
613
+ inner = TextNode("hello")
614
+ node = GridCards(items=((inner,),))
615
+ found = list(walk(node))
616
+ assert inner in found
@@ -1228,3 +1228,62 @@ class TestKeyboardKeys:
1228
1228
  para = first(parse("C++ is a language\n"), Paragraph)
1229
1229
  text = "".join(n.text for n in para.children if isinstance(n, TextNode))
1230
1230
  assert "C++" in text
1231
+
1232
+
1233
+ class TestGridCards:
1234
+ def test_bullet_list_items_become_cards(self) -> None:
1235
+ from mkdocs_to_confluence.ir import GridCards, Paragraph
1236
+ md = (
1237
+ '<div class="grid cards" markdown>\n'
1238
+ "- **Fast** — compiles quickly\n"
1239
+ "- **Secure** — no credentials stored\n"
1240
+ "</div>\n"
1241
+ )
1242
+ nodes = parse(md)
1243
+ gc = next(n for n in nodes if isinstance(n, GridCards))
1244
+ assert len(gc.items) == 2
1245
+ assert any(isinstance(n, Paragraph) for n in gc.items[0])
1246
+ assert any(isinstance(n, Paragraph) for n in gc.items[1])
1247
+
1248
+ def test_admonition_items_become_cards(self) -> None:
1249
+ from mkdocs_to_confluence.ir import Admonition, GridCards
1250
+ md = (
1251
+ '<div class="grid cards" markdown>\n'
1252
+ '\n'
1253
+ '!!! tip "Card 1"\n'
1254
+ ' First card content\n'
1255
+ '\n'
1256
+ '!!! info "Card 2"\n'
1257
+ ' Second card content\n'
1258
+ '\n'
1259
+ '</div>\n'
1260
+ )
1261
+ nodes = parse(md)
1262
+ gc = next(n for n in nodes if isinstance(n, GridCards))
1263
+ assert len(gc.items) == 2
1264
+ assert any(isinstance(n, Admonition) for n in gc.items[0])
1265
+ assert any(isinstance(n, Admonition) for n in gc.items[1])
1266
+
1267
+ def test_single_card(self) -> None:
1268
+ from mkdocs_to_confluence.ir import GridCards
1269
+ md = (
1270
+ '<div class="grid cards" markdown>\n'
1271
+ "- Only one card\n"
1272
+ "</div>\n"
1273
+ )
1274
+ nodes = parse(md)
1275
+ gc = next(n for n in nodes if isinstance(n, GridCards))
1276
+ assert len(gc.items) == 1
1277
+
1278
+ def test_three_cards(self) -> None:
1279
+ from mkdocs_to_confluence.ir import GridCards
1280
+ md = (
1281
+ '<div class="grid cards" markdown>\n'
1282
+ "- Card A\n"
1283
+ "- Card B\n"
1284
+ "- Card C\n"
1285
+ "</div>\n"
1286
+ )
1287
+ nodes = parse(md)
1288
+ gc = next(n for n in nodes if isinstance(n, GridCards))
1289
+ assert len(gc.items) == 3