mkdocs2confluence 0.10.0__tar.gz → 0.10.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.
Files changed (87) hide show
  1. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/PKG-INFO +3 -1
  2. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/README.md +2 -0
  3. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/pyproject.toml +1 -1
  4. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs2confluence.egg-info/PKG-INFO +3 -1
  5. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/cli.py +11 -6
  6. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/emitter/xhtml.py +13 -9
  7. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/parser/markdown.py +62 -8
  8. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/publisher/pipeline.py +6 -1
  9. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_emitter.py +26 -0
  10. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_internallinks.py +58 -0
  11. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_parser.py +43 -0
  12. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/LICENSE +0 -0
  13. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/setup.cfg +0 -0
  14. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  15. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  16. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  17. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  18. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  19. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/__init__.py +0 -0
  20. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  21. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  22. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/ir/document.py +0 -0
  23. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/ir/nodes.py +0 -0
  24. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  25. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  26. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/loader/config.py +0 -0
  27. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  28. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  29. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/loader/page.py +0 -0
  30. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  31. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  32. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  33. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  34. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  35. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  36. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  37. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  38. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  39. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  40. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  41. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  42. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preview/render.py +0 -0
  43. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/preview/server.py +0 -0
  44. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  45. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  46. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/sync/__init__.py +0 -0
  47. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/sync/anchoring.py +0 -0
  48. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/sync/command.py +0 -0
  49. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/sync/comments.py +0 -0
  50. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/sync/github.py +0 -0
  51. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/sync/platform.py +0 -0
  52. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/sync/state.py +0 -0
  53. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  54. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/abbrevs.py +0 -0
  55. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  56. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  57. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/footer.py +0 -0
  58. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  59. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  60. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  61. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_abbrevs.py +0 -0
  62. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_children_macro.py +0 -0
  63. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_cli.py +0 -0
  64. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_editlink.py +0 -0
  65. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_extra_css.py +0 -0
  66. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_footer.py +0 -0
  67. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_frontmatter.py +0 -0
  68. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_icons.py +0 -0
  69. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_images.py +0 -0
  70. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_ir.py +0 -0
  71. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_linkdefs.py +0 -0
  72. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_loader.py +0 -0
  73. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_mermaid.py +0 -0
  74. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_page_loader.py +0 -0
  75. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_pdf.py +0 -0
  76. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_preprocess.py +0 -0
  77. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_preview.py +0 -0
  78. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_publish_client.py +0 -0
  79. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_publish_config.py +0 -0
  80. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_publish_pipeline.py +0 -0
  81. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_server.py +0 -0
  82. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_sync_anchoring.py +0 -0
  83. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_sync_command.py +0 -0
  84. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_sync_comments.py +0 -0
  85. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_sync_github.py +0 -0
  86. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_sync_state.py +0 -0
  87. {mkdocs2confluence-0.10.0 → mkdocs2confluence-0.10.2}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.10.0
3
+ Version: 0.10.2
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
@@ -52,6 +52,8 @@ Dynamic: license-file
52
52
  [![Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
53
53
  [![mypy](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy-lang.org/)
54
54
  [![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
55
+ [![SLSA Level 3](https://slsa.dev/images/gh-badge-level3.svg)](https://slsa.dev)
56
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/jeckyl2010/mkdocs2confluence/badge)](https://securityscorecards.dev/viewer/?uri=github.com/jeckyl2010/mkdocs2confluence)
55
57
 
56
58
  A Python CLI tool that compiles MkDocs-flavoured Markdown into native Confluence storage XHTML and publishes it directly to Confluence Cloud. It is a **compiler/transpiler**, not an HTML converter — every construct maps to its native Confluence equivalent, so pages look and behave like hand-authored Confluence content.
57
59
 
@@ -11,6 +11,8 @@
11
11
  [![Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
12
12
  [![mypy](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy-lang.org/)
13
13
  [![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
14
+ [![SLSA Level 3](https://slsa.dev/images/gh-badge-level3.svg)](https://slsa.dev)
15
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/jeckyl2010/mkdocs2confluence/badge)](https://securityscorecards.dev/viewer/?uri=github.com/jeckyl2010/mkdocs2confluence)
14
16
 
15
17
  A Python CLI tool that compiles MkDocs-flavoured Markdown into native Confluence storage XHTML and publishes it directly to Confluence Cloud. It is a **compiler/transpiler**, not an HTML converter — every construct maps to its native Confluence equivalent, so pages look and behave like hand-authored Confluence content.
16
18
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.10.0"
3
+ version = "0.10.2"
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.10.0
3
+ Version: 0.10.2
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
@@ -52,6 +52,8 @@ Dynamic: license-file
52
52
  [![Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
53
53
  [![mypy](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy-lang.org/)
54
54
  [![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
55
+ [![SLSA Level 3](https://slsa.dev/images/gh-badge-level3.svg)](https://slsa.dev)
56
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/jeckyl2010/mkdocs2confluence/badge)](https://securityscorecards.dev/viewer/?uri=github.com/jeckyl2010/mkdocs2confluence)
55
57
 
56
58
  A Python CLI tool that compiles MkDocs-flavoured Markdown into native Confluence storage XHTML and publishes it directly to Confluence Cloud. It is a **compiler/transpiler**, not an HTML converter — every construct maps to its native Confluence equivalent, so pages look and behave like hand-authored Confluence content.
57
59
 
@@ -312,7 +312,7 @@ def _cmd_preview(args: argparse.Namespace) -> None:
312
312
  config = load_config(config_path)
313
313
  configure_styles(config.extra_styles)
314
314
 
315
- nodes = resolve_nav(config)
315
+ all_nodes = resolve_nav(config)
316
316
 
317
317
  section_given = bool(getattr(args, "section", None))
318
318
  page_given = bool(args.page)
@@ -326,8 +326,9 @@ def _cmd_preview(args: argparse.Namespace) -> None:
326
326
  sys.exit(1)
327
327
 
328
328
  # Resolve section subtree (both single-page and section-mode use this)
329
+ nodes = all_nodes
329
330
  if section_given:
330
- section_node = find_section(nodes, args.section) or find_section_by_folder(nodes, args.section)
331
+ section_node = find_section(all_nodes, args.section) or find_section_by_folder(all_nodes, args.section)
331
332
  if section_node is None:
332
333
  print(f"error: section '{args.section}' not found in nav.", file=sys.stderr)
333
334
  sys.exit(1)
@@ -347,7 +348,7 @@ def _cmd_preview(args: argparse.Namespace) -> None:
347
348
  out_dir, index_name = _parse_out_path(args.out)
348
349
  out_dir.mkdir(parents=True, exist_ok=True)
349
350
 
350
- link_map = build_link_map(nodes)
351
+ link_map = build_link_map(all_nodes)
351
352
  page_link_map = {
352
353
  node.title: f"{Path(node.docs_path).stem}.html"
353
354
  for node in pages
@@ -479,11 +480,12 @@ def _cmd_publish(args: argparse.Namespace) -> None:
479
480
  )
480
481
  sys.exit(1)
481
482
 
482
- nav_nodes = resolve_nav(config)
483
+ all_nav_nodes = resolve_nav(config)
484
+ nav_nodes = all_nav_nodes
483
485
 
484
486
  # Section filter (--section takes precedence; --page is a secondary filter)
485
487
  if getattr(args, "section", None):
486
- section_node = find_section(nav_nodes, args.section) or find_section_by_folder(nav_nodes, args.section)
488
+ section_node = find_section(all_nav_nodes, args.section) or find_section_by_folder(all_nav_nodes, args.section)
487
489
  if section_node is None:
488
490
  print(f"error: section '{args.section}' not found in nav.", file=sys.stderr)
489
491
  sys.exit(1)
@@ -529,7 +531,10 @@ def _cmd_publish(args: argparse.Namespace) -> None:
529
531
  file=sys.stderr,
530
532
  )
531
533
  sys.exit(1)
532
- plan = plan_publish(nav_nodes, client, config, conf_config, space_id=space_id, quiet=args.quiet)
534
+ plan = plan_publish(
535
+ nav_nodes, client, config, conf_config,
536
+ space_id=space_id, quiet=args.quiet, full_nav_nodes=all_nav_nodes,
537
+ )
533
538
  # --prune is silently disabled for partial publishes (--page / --section)
534
539
  # because published_ids would only cover the subset, not the full nav.
535
540
  partial = bool(getattr(args, "page", None) or getattr(args, "section", None))
@@ -422,15 +422,19 @@ def _emit_task_item(item: ListItem) -> str:
422
422
 
423
423
 
424
424
  def _emit_list_item(item: ListItem) -> str:
425
- if any(isinstance(c, _LIST_BLOCK_TYPES) for c in item.children):
426
- # Loose list item: children are block nodes (e.g. nested list or paragraph).
427
- inner = emit(item.children)
428
- return f" <li>{inner.strip()}</li>\n"
429
- # Tight list item: children are inline nodes. Confluence requires a <p>
430
- # wrapper inside <li> for structured inline macros (e.g. <ac:link>) to
431
- # render — without it they are silently stripped by the storage parser.
432
- inner = _emit_inlines(item.children)
433
- return f" <li><p>{inner}</p></li>\n"
425
+ first_block = next(
426
+ (i for i, c in enumerate(item.children) if isinstance(c, _LIST_BLOCK_TYPES)), None
427
+ )
428
+ if first_block is None:
429
+ # All inline standard tight rendering.
430
+ inner = _emit_inlines(item.children)
431
+ return f" <li><p>{inner}</p></li>\n"
432
+ # Mixed: inline prefix (if any) wrapped in <p>, followed by block children.
433
+ parts: list[str] = []
434
+ if first_block > 0:
435
+ parts.append(f"<p>{_emit_inlines(item.children[:first_block])}</p>\n")
436
+ parts.append(emit(item.children[first_block:]))
437
+ return f" <li>{''.join(parts).strip()}</li>\n"
434
438
 
435
439
 
436
440
  def _emit_table(node: Table) -> str:
@@ -158,6 +158,7 @@ class _BlockQuoteToken:
158
158
  class _ListItemData:
159
159
  text: str
160
160
  task: bool | None = None # None=regular, True=checked, False=unchecked
161
+ sub_tokens: list[_Token] = field(default_factory=list)
161
162
 
162
163
 
163
164
  @dataclass
@@ -501,7 +502,8 @@ def _tokenize(text: str) -> list[_Token]:
501
502
  task = task_m.group("state").lower() == "x"
502
503
  item_text = task_m.group("rest")
503
504
  i += 1
504
- # Collect continuation lines (non-blank, non-list) into this item.
505
+ # Collect continuation and indented sub-list lines.
506
+ sub_lines: list[str] = []
505
507
  while i < len(lines) and lines[i].strip():
506
508
  cont = lines[i]
507
509
  bullet_m = _BULLET_RE.match(cont)
@@ -510,9 +512,18 @@ def _tokenize(text: str) -> list[_Token]:
510
512
  ordered_m2 and not ordered_m2.group("indent")
511
513
  ):
512
514
  break
513
- item_text = item_text.rstrip() + " " + cont.strip()
515
+ if (bullet_m and bullet_m.group("indent")) or (
516
+ ordered_m2 and ordered_m2.group("indent")
517
+ ) or sub_lines:
518
+ sub_lines.append(cont)
519
+ else:
520
+ item_text = item_text.rstrip() + " " + cont.strip()
514
521
  i += 1
515
- list_items.append(_ListItemData(text=item_text, task=task))
522
+ _sub: list[_Token] = []
523
+ if sub_lines:
524
+ _ind = min(len(ln) - len(ln.lstrip()) for ln in sub_lines if ln.strip())
525
+ _sub = _tokenize("\n".join(ln[_ind:] for ln in sub_lines))
526
+ list_items.append(_ListItemData(text=item_text, task=task, sub_tokens=_sub))
516
527
  tokens.append(_BulletListToken(items=list_items))
517
528
  continue
518
529
 
@@ -537,7 +548,8 @@ def _tokenize(text: str) -> list[_Token]:
537
548
  break
538
549
  item_text = om.group("text")
539
550
  i += 1
540
- # Collect continuation lines (non-blank, non-list) into this item.
551
+ # Collect continuation and indented sub-list lines.
552
+ sub_lines_ord: list[str] = []
541
553
  while i < len(lines) and lines[i].strip():
542
554
  cont = lines[i]
543
555
  ordered_m3 = _ORDERED_RE.match(cont)
@@ -546,9 +558,18 @@ def _tokenize(text: str) -> list[_Token]:
546
558
  bullet_m2 and not bullet_m2.group("indent")
547
559
  ):
548
560
  break
549
- item_text = item_text.rstrip() + " " + cont.strip()
561
+ if (ordered_m3 and ordered_m3.group("indent")) or (
562
+ bullet_m2 and bullet_m2.group("indent")
563
+ ) or sub_lines_ord:
564
+ sub_lines_ord.append(cont)
565
+ else:
566
+ item_text = item_text.rstrip() + " " + cont.strip()
550
567
  i += 1
551
- ord_items.append(_ListItemData(text=item_text))
568
+ _sub_ord: list[_Token] = []
569
+ if sub_lines_ord:
570
+ _ind_ord = min(len(ln) - len(ln.lstrip()) for ln in sub_lines_ord if ln.strip())
571
+ _sub_ord = _tokenize("\n".join(ln[_ind_ord:] for ln in sub_lines_ord))
572
+ ord_items.append(_ListItemData(text=item_text, sub_tokens=_sub_ord))
552
573
  tokens.append(_OrderedListToken(start=start, items=ord_items))
553
574
  continue
554
575
 
@@ -1030,6 +1051,32 @@ class _OpenSection:
1030
1051
  children: list[IRNode] = field(default_factory=list)
1031
1052
 
1032
1053
 
1054
+ def _build_sub_list_nodes(tokens: list[_Token], fn_map: dict[str, int] | None) -> tuple[IRNode, ...]:
1055
+ """Recursively convert sub-list tokens (from indented continuation) to IR nodes."""
1056
+ nodes: list[IRNode] = []
1057
+ for token in tokens:
1058
+ if isinstance(token, _BulletListToken):
1059
+ items = tuple(
1060
+ ListItem(
1061
+ children=_parse_inline(item.text, fn_map=fn_map)
1062
+ + _build_sub_list_nodes(item.sub_tokens, fn_map),
1063
+ task=item.task,
1064
+ )
1065
+ for item in token.items
1066
+ )
1067
+ nodes.append(BulletList(items=items))
1068
+ elif isinstance(token, _OrderedListToken):
1069
+ items = tuple(
1070
+ ListItem(
1071
+ children=_parse_inline(item.text, fn_map=fn_map)
1072
+ + _build_sub_list_nodes(item.sub_tokens, fn_map),
1073
+ )
1074
+ for item in token.items
1075
+ )
1076
+ nodes.append(OrderedList(items=items, start=token.start))
1077
+ return tuple(nodes)
1078
+
1079
+
1033
1080
  def _build_tree(
1034
1081
  tokens: list[_Token],
1035
1082
  fn_map: dict[str, int] | None = None,
@@ -1101,14 +1148,21 @@ def _build_tree(
1101
1148
 
1102
1149
  elif isinstance(token, _BulletListToken):
1103
1150
  items = tuple(
1104
- ListItem(children=_parse_inline(item.text, fn_map=_fn), task=item.task)
1151
+ ListItem(
1152
+ children=_parse_inline(item.text, fn_map=_fn)
1153
+ + _build_sub_list_nodes(item.sub_tokens, _fn),
1154
+ task=item.task,
1155
+ )
1105
1156
  for item in token.items
1106
1157
  )
1107
1158
  _append_content(BulletList(items=items), stack, root)
1108
1159
 
1109
1160
  elif isinstance(token, _OrderedListToken):
1110
1161
  items = tuple(
1111
- ListItem(children=_parse_inline(item.text, fn_map=_fn))
1162
+ ListItem(
1163
+ children=_parse_inline(item.text, fn_map=_fn)
1164
+ + _build_sub_list_nodes(item.sub_tokens, _fn),
1165
+ )
1112
1166
  for item in token.items
1113
1167
  )
1114
1168
  _append_content(OrderedList(items=items, start=token.start), stack, root)
@@ -238,15 +238,20 @@ def plan_publish(
238
238
  *,
239
239
  space_id: str,
240
240
  quiet: bool = False,
241
+ full_nav_nodes: list[NavNode] | None = None,
241
242
  ) -> list[PageAction]:
242
243
  """Build a publish plan for the entire nav tree.
243
244
 
244
245
  Section nodes become native Confluence folders so the hierarchy is
245
246
  preserved visually. The actual find-or-create for folders is deferred
246
247
  to execute time once parent folder IDs are known.
248
+
249
+ ``full_nav_nodes``, when provided, is used to build the link map so that
250
+ cross-section internal links resolve correctly even when publishing only a
251
+ subset of the nav (e.g. ``--section``).
247
252
  """
248
253
  actions: list[PageAction] = []
249
- link_map = build_link_map(nav_nodes)
254
+ link_map = build_link_map(full_nav_nodes if full_nav_nodes is not None else nav_nodes)
250
255
  if not quiet:
251
256
  print("Planning...")
252
257
  _plan_nodes(nav_nodes, client, config, space_id, conf_config.parent_page_id, False, actions, link_map, quiet=quiet)
@@ -235,6 +235,32 @@ class TestListEmitters:
235
235
  assert "<ul>" in out
236
236
  assert "<ac:task-list>" not in out
237
237
 
238
+ def test_nested_bullet_list(self) -> None:
239
+ nested = BulletList(items=(ListItem((TextNode("child"),)),))
240
+ items = (ListItem((TextNode("parent"), nested)),)
241
+ out = emit((BulletList(items=items),))
242
+ assert out.count("<ul>") == 2
243
+ assert out.count("</ul>") == 2
244
+ assert "<p>parent</p>" in out
245
+ assert "<p>child</p>" in out
246
+
247
+ def test_nested_ordered_list(self) -> None:
248
+ nested = OrderedList(items=(ListItem((TextNode("sub"),)),))
249
+ items = (ListItem((TextNode("top"), nested)),)
250
+ out = emit((OrderedList(items=items),))
251
+ assert "<ol>" in out
252
+ assert "<ul>" not in out
253
+ assert "<p>top</p>" in out
254
+ assert "<p>sub</p>" in out
255
+
256
+ def test_nested_list_no_inline_prefix(self) -> None:
257
+ """A list item with only a nested sub-list (no inline text) renders cleanly."""
258
+ nested = BulletList(items=(ListItem((TextNode("only child"),)),))
259
+ items = (ListItem((nested,)),)
260
+ out = emit((BulletList(items=items),))
261
+ assert out.count("<ul>") == 2
262
+ assert "<p>only child</p>" in out
263
+
238
264
 
239
265
  class TestHorizontalRule:
240
266
  def test_hr(self) -> None:
@@ -364,3 +364,61 @@ def test_unresolved_md_link_with_anchor_degrades_to_label():
364
364
  xhtml = emit((Paragraph(children=(link,)),))
365
365
  assert "Hello World" in xhtml
366
366
  assert "hello-worl.md" not in xhtml
367
+
368
+
369
+ def test_link_inside_admonition_resolves():
370
+ """A relative .md link inside an admonition body must resolve to ac:link.
371
+
372
+ Regression: links inside admonitions were reported as showing raw label
373
+ text instead of Confluence hyperlinks when the target page was in a
374
+ different section (cross-section link).
375
+ """
376
+ from mkdocs_to_confluence.emitter.xhtml import emit
377
+ from mkdocs_to_confluence.parser.markdown import parse
378
+
379
+ nav = _make_nav([("proposals/2026/authentication-procedures.md", "Auth Procedures")])
380
+ link_map = build_link_map(nav)
381
+ current_path = "architecture/identity/keycloak.md"
382
+
383
+ md = '!!! info "Related document"\n See [Auth Procedures](../../proposals/2026/authentication-procedures.md).\n'
384
+ nodes = parse(md)
385
+ resolved = resolve_internal_links(nodes, link_map, current_path)
386
+ xhtml = emit(resolved)
387
+
388
+ assert 'ri:content-title="Auth Procedures"' in xhtml
389
+ assert "authentication-procedures.md" not in xhtml
390
+
391
+
392
+ def test_cross_section_link_resolves_when_full_nav_used():
393
+ """build_link_map must include all nav pages so cross-section links resolve.
394
+
395
+ The root cause of the admonition link bug: when publishing with --section,
396
+ the link_map was built from the section subtree only. Cross-section targets
397
+ were missing and links degraded to label text.
398
+ """
399
+ from mkdocs_to_confluence.emitter.xhtml import emit
400
+ from mkdocs_to_confluence.parser.markdown import parse
401
+
402
+ # Two separate sections
403
+ full_nav = _make_nav([
404
+ ("section-a/page.md", "Page A"),
405
+ ("section-b/target.md", "Target Page"),
406
+ ])
407
+ section_a_nav = _make_nav([("section-a/page.md", "Page A")])
408
+
409
+ link_map_full = build_link_map(full_nav)
410
+ link_map_section_only = build_link_map(section_a_nav)
411
+
412
+ md = "See [Target Page](../section-b/target.md).\n"
413
+ nodes = parse(md)
414
+
415
+ # With full nav: link resolves
416
+ resolved_full = resolve_internal_links(nodes, link_map_full, "section-a/page.md")
417
+ xhtml_full = emit(resolved_full)
418
+ assert 'ri:content-title="Target Page"' in xhtml_full
419
+
420
+ # With section-only nav: link degrades to label text
421
+ resolved_section = resolve_internal_links(nodes, link_map_section_only, "section-a/page.md")
422
+ xhtml_section = emit(resolved_section)
423
+ assert "Target Page" in xhtml_section
424
+ assert "ri:content-title" not in xhtml_section
@@ -1021,6 +1021,49 @@ class TestListParsing:
1021
1021
  )
1022
1022
  assert "Continuation" in item_text or "First point" in item_text
1023
1023
 
1024
+ def test_nested_bullet_list(self) -> None:
1025
+ md = "- item 1\n - nested 1\n - nested 2\n- item 2\n"
1026
+ nodes = parse(md)
1027
+ bl = first(nodes, BulletList)
1028
+ assert isinstance(bl, BulletList)
1029
+ assert len(bl.items) == 2
1030
+ # First item must have a nested BulletList as a child.
1031
+ nested = next((c for c in bl.items[0].children if isinstance(c, BulletList)), None)
1032
+ assert nested is not None, "expected nested BulletList in first item"
1033
+ assert len(nested.items) == 2
1034
+ assert nested.items[0].children[0].text == "nested 1" # type: ignore[union-attr]
1035
+ assert nested.items[1].children[0].text == "nested 2" # type: ignore[union-attr]
1036
+
1037
+ def test_nested_ordered_list(self) -> None:
1038
+ md = "1. first\n 1. sub one\n 2. sub two\n2. second\n"
1039
+ nodes = parse(md)
1040
+ ol = first(nodes, OrderedList)
1041
+ assert isinstance(ol, OrderedList)
1042
+ nested = next((c for c in ol.items[0].children if isinstance(c, OrderedList)), None)
1043
+ assert nested is not None, "expected nested OrderedList in first item"
1044
+ assert len(nested.items) == 2
1045
+
1046
+ def test_deeply_nested_bullet_list(self) -> None:
1047
+ md = "- a\n - b\n - c\n"
1048
+ nodes = parse(md)
1049
+ bl = first(nodes, BulletList)
1050
+ assert isinstance(bl, BulletList)
1051
+ level2 = next((c for c in bl.items[0].children if isinstance(c, BulletList)), None)
1052
+ assert level2 is not None
1053
+ level3 = next((c for c in level2.items[0].children if isinstance(c, BulletList)), None)
1054
+ assert level3 is not None
1055
+ assert level3.items[0].children[0].text == "c" # type: ignore[union-attr]
1056
+
1057
+ def test_mixed_nested_list(self) -> None:
1058
+ """Bullet list item containing an ordered sub-list."""
1059
+ md = "- item\n 1. sub one\n 2. sub two\n"
1060
+ nodes = parse(md)
1061
+ bl = first(nodes, BulletList)
1062
+ assert isinstance(bl, BulletList)
1063
+ nested_ol = next((c for c in bl.items[0].children if isinstance(c, OrderedList)), None)
1064
+ assert nested_ol is not None
1065
+ assert len(nested_ol.items) == 2
1066
+
1024
1067
 
1025
1068
  # ── Definition list parsing ───────────────────────────────────────────────────
1026
1069