mkdocs2confluence 0.7.10__tar.gz → 0.7.12__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 (72) hide show
  1. {mkdocs2confluence-0.7.10/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.7.12}/PKG-INFO +1 -1
  2. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/pyproject.toml +1 -1
  3. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12/src/mkdocs2confluence.egg-info}/PKG-INFO +1 -1
  4. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/emitter/xhtml.py +31 -5
  5. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/ir/nodes.py +22 -4
  6. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/transforms/abbrevs.py +29 -35
  7. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_abbrevs.py +51 -101
  8. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_emitter.py +18 -0
  9. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/LICENSE +0 -0
  10. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/README.md +0 -0
  11. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/setup.cfg +0 -0
  12. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  13. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  14. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  15. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  16. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  17. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/__init__.py +0 -0
  18. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/cli.py +0 -0
  19. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  20. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  21. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/ir/document.py +0 -0
  22. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  23. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  24. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/loader/config.py +0 -0
  25. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  26. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  27. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/loader/page.py +0 -0
  28. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  29. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  30. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  31. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  32. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  33. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  34. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  35. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  36. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  37. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  38. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  39. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  40. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  41. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preview/render.py +0 -0
  42. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/preview/server.py +0 -0
  43. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  44. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  45. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  46. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  47. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  48. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  49. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  50. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  51. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  52. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_cli.py +0 -0
  53. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_editlink.py +0 -0
  54. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_extra_css.py +0 -0
  55. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_frontmatter.py +0 -0
  56. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_icons.py +0 -0
  57. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_images.py +0 -0
  58. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_internallinks.py +0 -0
  59. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_ir.py +0 -0
  60. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_linkdefs.py +0 -0
  61. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_loader.py +0 -0
  62. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_mermaid.py +0 -0
  63. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_page_loader.py +0 -0
  64. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_parser.py +0 -0
  65. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_pdf.py +0 -0
  66. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_preprocess.py +0 -0
  67. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_preview.py +0 -0
  68. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_publish_client.py +0 -0
  69. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_publish_config.py +0 -0
  70. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_publish_pipeline.py +0 -0
  71. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_server.py +0 -0
  72. {mkdocs2confluence-0.7.10 → mkdocs2confluence-0.7.12}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.7.10
3
+ Version: 0.7.12
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.7.10"
3
+ version = "0.7.12"
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.7.10
3
+ Version: 0.7.12
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
@@ -23,6 +23,7 @@ from urllib.parse import urlparse
23
23
 
24
24
  from mkdocs_to_confluence.ir.nodes import (
25
25
  AbbrevFootnoteNode,
26
+ AbbrevGlossaryBlock,
26
27
  Admonition,
27
28
  BlockQuote,
28
29
  BoldNode,
@@ -191,6 +192,8 @@ def _emit_node(node: IRNode) -> str:
191
192
  return _emit_front_matter(node)
192
193
  if isinstance(node, FootnoteBlock):
193
194
  return _emit_footnote_block(node)
195
+ if isinstance(node, AbbrevGlossaryBlock):
196
+ return _emit_abbrev_glossary_block(node)
194
197
  if isinstance(node, GridCards):
195
198
  return _emit_grid_cards(node)
196
199
  if isinstance(node, UnsupportedBlock):
@@ -557,17 +560,40 @@ def _emit_footnote_ref(node: FootnoteRef) -> str:
557
560
 
558
561
 
559
562
  def _emit_abbrev_footnote(node: AbbrevFootnoteNode) -> str:
560
- """Abbreviation term + inline Confluence footnote macro with its definition."""
563
+ """Inline: ABBR + superscript anchor-link to the glossary entry."""
564
+ anchor = html.escape(f"abbr-{node.number}")
565
+ num = html.escape(str(node.number))
561
566
  term = html.escape(node.abbr)
562
- defn = html.escape(node.definition)
563
567
  return (
564
568
  f"{term}"
565
- f'<ac:structured-macro ac:name="footnote" ac:schema-version="1">'
566
- f"<ac:rich-text-body><p>{defn}</p></ac:rich-text-body>"
567
- f"</ac:structured-macro>"
569
+ f"<sup>"
570
+ f'<ac:link ac:anchor="{anchor}">'
571
+ f"<ac:plain-text-link-body><![CDATA[{num}]]></ac:plain-text-link-body>"
572
+ f"</ac:link>"
573
+ f"</sup>"
568
574
  )
569
575
 
570
576
 
577
+ def _emit_abbrev_glossary_block(node: AbbrevGlossaryBlock) -> str:
578
+ """End-of-page abbreviations list with Confluence anchor targets."""
579
+ parts: list[str] = ["<hr />\n<h6>Abbreviations</h6>\n<ol>\n"]
580
+ for fn in node.footnoted:
581
+ anchor = html.escape(f"abbr-{fn.number}")
582
+ anchor_macro = (
583
+ f'<ac:structured-macro ac:name="anchor">'
584
+ f'<ac:parameter ac:name=""><![CDATA[{anchor}]]></ac:parameter>'
585
+ f"</ac:structured-macro>"
586
+ )
587
+ abbr = html.escape(fn.abbr)
588
+ defn = html.escape(fn.definition)
589
+ parts.append(f"<li>{anchor_macro}<strong>{abbr}</strong> — {defn}</li>\n")
590
+ for abbr, defn in node.extras:
591
+ # No anchor — these only appeared in headings/titles, no inline superscript links here.
592
+ parts.append(f"<li><strong>{html.escape(abbr)}</strong> — {html.escape(defn)}</li>\n")
593
+ parts.append("</ol>\n")
594
+ return "".join(parts)
595
+
596
+
571
597
  def _emit_footnote_block(node: FootnoteBlock) -> str:
572
598
  """Footnotes section: heading + ordered list with anchor targets."""
573
599
  items: list[str] = []
@@ -433,15 +433,33 @@ class FrontMatter(IRNode):
433
433
 
434
434
  @dataclass(frozen=True)
435
435
  class AbbrevFootnoteNode(IRNode):
436
- """An abbreviation annotated with an inline Confluence footnote.
436
+ """Inline: abbreviated term with a superscript anchor-link to the glossary.
437
437
 
438
- The emitter outputs the abbreviated term immediately followed by a
439
- ``footnote`` macro containing the full definition. Confluence collects
440
- all footnote macros and renders their bodies at the bottom of the page.
438
+ The emitter renders ``ABBR<sup>[N]</sup>`` where ``[N]`` links to the
439
+ corresponding entry in the end-of-page :class:`AbbrevGlossaryBlock`.
441
440
  """
442
441
 
443
442
  abbr: str
444
443
  definition: str
444
+ number: int # 1-based, assigned by the transform in order of first encounter
445
+
446
+
447
+ @dataclass(frozen=True)
448
+ class AbbrevGlossaryBlock(IRNode):
449
+ """End-of-page abbreviations reference block.
450
+
451
+ Rendered as a numbered list (with Confluence anchor targets for the
452
+ back-links) followed by an optional bullet list of abbreviations that
453
+ only appeared in headings or other non-expandable contexts.
454
+
455
+ Attributes:
456
+ footnoted: Abbreviations annotated inline, ordered by first encounter.
457
+ extras: ``(abbr, definition)`` pairs for abbreviations that could
458
+ not be annotated inline, sorted alphabetically.
459
+ """
460
+
461
+ footnoted: tuple[AbbrevFootnoteNode, ...]
462
+ extras: tuple[tuple[str, str], ...]
445
463
 
446
464
 
447
465
  # ── Footnotes ────────────────────────────────────────────────────────────────
@@ -30,13 +30,13 @@ from dataclasses import replace
30
30
 
31
31
  from mkdocs_to_confluence.ir.nodes import (
32
32
  AbbrevFootnoteNode,
33
+ AbbrevGlossaryBlock,
33
34
  Admonition,
34
35
  BlockQuote,
35
36
  BoldNode,
36
37
  BulletList,
37
38
  ContentTabs,
38
39
  Expandable,
39
- HorizontalRule,
40
40
  IRNode,
41
41
  ItalicNode,
42
42
  LinkNode,
@@ -60,13 +60,18 @@ class _State:
60
60
 
61
61
  def __init__(self, abbrevs: dict[str, str]) -> None:
62
62
  self.abbrevs = abbrevs
63
- self.expanded: set[str] = set()
63
+ self._expanded_list: list[str] = [] # ordered by first encounter
64
+ self._expanded_set: set[str] = set() # fast membership test
64
65
  # Pre-compile word-boundary patterns once.
65
66
  self._patterns: dict[str, re.Pattern[str]] = {
66
67
  abbr: re.compile(r"\b" + re.escape(abbr) + r"\b")
67
68
  for abbr in abbrevs
68
69
  }
69
70
 
71
+ @property
72
+ def expanded(self) -> set[str]:
73
+ return self._expanded_set
74
+
70
75
  def expand_to_nodes(self, text: str) -> tuple[IRNode, ...]:
71
76
  """Split *text* around the first unexpanded abbreviation.
72
77
 
@@ -75,7 +80,7 @@ class _State:
75
80
  """
76
81
  best: tuple[int, int, str] | None = None
77
82
  for abbr in self.abbrevs:
78
- if abbr in self.expanded:
83
+ if abbr in self._expanded_set:
79
84
  continue
80
85
  m = self._patterns[abbr].search(text)
81
86
  if m and (best is None or m.start() < best[0]):
@@ -85,11 +90,13 @@ class _State:
85
90
  return (TextNode(text),) if text else ()
86
91
 
87
92
  start, end, abbr = best
88
- self.expanded.add(abbr)
93
+ self._expanded_list.append(abbr)
94
+ self._expanded_set.add(abbr)
95
+ number = len(self._expanded_list) # 1-based
89
96
  nodes: list[IRNode] = []
90
97
  if text[:start]:
91
98
  nodes.append(TextNode(text[:start]))
92
- nodes.append(AbbrevFootnoteNode(abbr=abbr, definition=self.abbrevs[abbr]))
99
+ nodes.append(AbbrevFootnoteNode(abbr=abbr, definition=self.abbrevs[abbr], number=number))
93
100
  nodes.extend(self.expand_to_nodes(text[end:]))
94
101
  return tuple(nodes)
95
102
 
@@ -197,21 +204,6 @@ def _find_mentioned(text: str, abbrevs: dict[str, str]) -> set[str]:
197
204
  }
198
205
 
199
206
 
200
- def _build_glossary_section(terms: dict[str, str]) -> tuple[IRNode, ...]:
201
- """Return an HR + h6 ``Section`` listing abbreviations that could not be footnoted."""
202
- items = tuple(
203
- ListItem(children=(Paragraph(children=(TextNode(f"{abbr} — {defn}"),)),))
204
- for abbr, defn in sorted(terms.items())
205
- )
206
- section = Section(
207
- level=6,
208
- anchor="glossary",
209
- title=(TextNode("Glossary"),),
210
- children=(BulletList(items=items),),
211
- )
212
- return (HorizontalRule(), section)
213
-
214
-
215
207
  # ── Public API ────────────────────────────────────────────────────────────────
216
208
 
217
209
 
@@ -229,15 +221,15 @@ def apply_abbreviations(
229
221
  :func:`~mkdocs_to_confluence.preprocess.abbrevs.extract_abbreviations`.
230
222
  page_text: The preprocessed page text (after stripping abbreviation
231
223
  definition lines) used to detect which abbreviations are
232
- actually present on the page. Used to determine which
233
- abbreviations need a glossary entry.
224
+ actually present on the page.
234
225
 
235
226
  Returns:
236
- Modified node tuple. Abbreviations in body text are replaced with an
237
- :class:`~mkdocs_to_confluence.ir.nodes.AbbrevFootnoteNode` on first
238
- occurrence. A ``Glossary`` section is appended only for abbreviations
239
- that were detected in *page_text* but never footnoted (e.g. they only
240
- appeared in headings or table headers).
227
+ Modified node tuple. Abbreviations in body text receive an inline
228
+ superscript anchor-link (:class:`AbbrevFootnoteNode`). An
229
+ :class:`AbbrevGlossaryBlock` is appended when any abbreviation was
230
+ mentioned on the page, listing all footnoted entries (with anchor
231
+ targets for the back-links) plus any abbreviations that only appeared
232
+ in headings or other non-expandable contexts.
241
233
  """
242
234
  if not abbrevs:
243
235
  return nodes
@@ -246,15 +238,17 @@ def apply_abbreviations(
246
238
  transformed = tuple(_transform_block(n, state) for n in nodes)
247
239
 
248
240
  mentioned = _find_mentioned(page_text, abbrevs)
249
- # Only add a glossary entry for abbreviations that were never footnoted.
250
- glossary_needed = {
251
- abbr: abbrevs[abbr]
252
- for abbr in mentioned
253
- if abbr not in state.expanded
254
- }
241
+ footnoted = tuple(
242
+ AbbrevFootnoteNode(abbr=abbr, definition=abbrevs[abbr], number=i + 1)
243
+ for i, abbr in enumerate(state._expanded_list)
244
+ )
245
+ extras = tuple(
246
+ (abbr, abbrevs[abbr])
247
+ for abbr in sorted(mentioned - state._expanded_set)
248
+ )
255
249
 
256
- if glossary_needed:
257
- transformed = transformed + _build_glossary_section(glossary_needed)
250
+ if footnoted or extras:
251
+ transformed = transformed + (AbbrevGlossaryBlock(footnoted=footnoted, extras=extras),)
258
252
 
259
253
  return transformed
260
254
 
@@ -4,11 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  from mkdocs_to_confluence.ir.nodes import (
6
6
  AbbrevFootnoteNode,
7
+ AbbrevGlossaryBlock,
7
8
  Admonition,
8
9
  BoldNode,
9
- BulletList,
10
10
  CodeBlock,
11
- HorizontalRule,
12
11
  LinkNode,
13
12
  Paragraph,
14
13
  Section,
@@ -89,17 +88,22 @@ def test_expands_first_occurrence_as_footnote():
89
88
  para = result[0]
90
89
  assert isinstance(para, Paragraph)
91
90
  children = para.children
92
- # Should be: TextNode("The ") + AbbrevFootnoteNode + TextNode(" platform handles IAM requests.")
91
+ # TextNode("The ") + AbbrevFootnoteNode + TextNode(" platform handles IAM requests.")
93
92
  assert len(children) == 3
94
- assert isinstance(children[0], TextNode)
95
- assert children[0].text == "The "
96
- assert isinstance(children[1], AbbrevFootnoteNode)
97
- assert children[1].abbr == "IAM"
98
- assert children[1].definition == "Identity and Access Management"
93
+ assert isinstance(children[0], TextNode) and children[0].text == "The "
94
+ fn = children[1]
95
+ assert isinstance(fn, AbbrevFootnoteNode)
96
+ assert fn.abbr == "IAM"
97
+ assert fn.definition == "Identity and Access Management"
98
+ assert fn.number == 1
99
99
  # Second occurrence left as plain text
100
100
  assert isinstance(children[2], TextNode)
101
101
  assert "IAM requests." in children[2].text
102
- assert "Identity and Access Management" not in children[2].text
102
+ # Glossary block appended
103
+ glossary = result[1]
104
+ assert isinstance(glossary, AbbrevGlossaryBlock)
105
+ assert glossary.footnoted[0].abbr == "IAM"
106
+ assert glossary.footnoted[0].number == 1
103
107
 
104
108
 
105
109
  def test_expands_multiple_different_abbrevs():
@@ -107,11 +111,12 @@ def test_expands_multiple_different_abbrevs():
107
111
  nodes = (_para("Use IAM and RBAC for access control."),)
108
112
  result = apply_abbreviations(nodes, abbrevs, page_text="Use IAM and RBAC for access control.")
109
113
  para = result[0]
110
- assert isinstance(para, Paragraph)
111
114
  footnotes = [c for c in para.children if isinstance(c, AbbrevFootnoteNode)]
112
115
  abbrs = {fn.abbr for fn in footnotes}
113
116
  assert "IAM" in abbrs
114
117
  assert "RBAC" in abbrs
118
+ numbers = sorted(fn.number for fn in footnotes)
119
+ assert numbers == [1, 2]
115
120
 
116
121
 
117
122
  def test_no_expand_when_no_abbrevs():
@@ -122,65 +127,40 @@ def test_no_expand_when_no_abbrevs():
122
127
 
123
128
  def test_no_expand_in_section_heading():
124
129
  abbrevs = {"IAM": "Identity and Access Management"}
125
- section = Section(
126
- level=2,
127
- anchor="iam",
128
- title=(TextNode("IAM Platform"),),
129
- children=(),
130
- )
130
+ section = Section(level=2, anchor="iam", title=(TextNode("IAM Platform"),), children=())
131
131
  result = apply_abbreviations((section,), abbrevs, page_text="IAM Platform")
132
132
  heading_text = result[0].title[0].text # type: ignore[union-attr]
133
- assert heading_text == "IAM Platform" # not expanded
133
+ assert heading_text == "IAM Platform" # not annotated
134
134
 
135
135
 
136
- def test_glossary_appended_for_heading_only_abbrev():
136
+ def test_glossary_block_appended_for_heading_only_abbrev():
137
137
  abbrevs = {"IAM": "Identity and Access Management"}
138
- section = Section(
139
- level=2,
140
- anchor="iam",
141
- title=(TextNode("IAM Platform"),),
142
- children=(),
143
- )
138
+ section = Section(level=2, anchor="iam", title=(TextNode("IAM Platform"),), children=())
144
139
  result = apply_abbreviations((section,), abbrevs, page_text="IAM Platform")
145
- # IAM only in heading (unsafe) no footnote → glossary fallback appended
146
- assert len(result) == 3
147
- assert isinstance(result[1], HorizontalRule)
148
- glossary = result[2]
149
- assert isinstance(glossary, Section)
150
- assert glossary.anchor == "glossary"
151
- assert glossary.level == 6
152
- bullet_list = glossary.children[0]
153
- assert isinstance(bullet_list, BulletList)
154
- item_text = bullet_list.items[0].children[0].children[0].text # type: ignore[union-attr]
155
- assert "IAM" in item_text
156
- assert "Identity and Access Management" in item_text
140
+ assert len(result) == 2
141
+ glossary = result[1]
142
+ assert isinstance(glossary, AbbrevGlossaryBlock)
143
+ assert len(glossary.footnoted) == 0
144
+ assert glossary.extras == (("IAM", "Identity and Access Management"),)
157
145
 
158
146
 
159
147
  def test_no_glossary_when_abbrev_footnoted_inline():
160
148
  abbrevs = {"IAM": "Identity and Access Management"}
161
149
  nodes = (_para("The IAM platform."),)
162
150
  result = apply_abbreviations(nodes, abbrevs, page_text="The IAM platform.")
163
- # Footnoted inline → no glossary needed
164
- assert len(result) == 1
165
- assert isinstance(result[0], Paragraph)
151
+ assert len(result) == 2
152
+ assert isinstance(result[1], AbbrevGlossaryBlock)
153
+ assert len(result[1].extras) == 0
166
154
 
167
155
 
168
156
  def test_no_expand_in_table_header_cell():
169
157
  abbrevs = {"API": "Application Programming Interface"}
170
- header = TableRow(cells=(
171
- TableCell(children=(TextNode("API Endpoint"),), is_header=True),
172
- ))
173
- body = TableRow(cells=(
174
- TableCell(children=(TextNode("The API docs"),), is_header=False),
175
- ))
158
+ header = TableRow(cells=(TableCell(children=(TextNode("API Endpoint"),), is_header=True),))
159
+ body = TableRow(cells=(TableCell(children=(TextNode("The API docs"),), is_header=False),))
176
160
  table = Table(header=header, rows=(body,))
177
161
  result = apply_abbreviations((table,), abbrevs, page_text="API Endpoint The API docs")
178
-
179
- # Header cell: not expanded
180
162
  header_text = result[0].header.cells[0].children[0].text # type: ignore[union-attr]
181
163
  assert header_text == "API Endpoint"
182
-
183
- # Body cell: first occurrence footnoted
184
164
  body_children = result[0].rows[0].cells[0].children # type: ignore[union-attr]
185
165
  assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "API" for c in body_children)
186
166
 
@@ -188,14 +168,11 @@ def test_no_expand_in_table_header_cell():
188
168
  def test_no_expand_in_admonition_title():
189
169
  abbrevs = {"TLS": "Transport Layer Security"}
190
170
  admonition = Admonition(
191
- kind="note",
192
- title="TLS Configuration",
171
+ kind="note", title="TLS Configuration",
193
172
  children=(_para("Use TLS for encryption."),),
194
173
  )
195
174
  result = apply_abbreviations((admonition,), abbrevs, page_text="TLS Configuration Use TLS for encryption.")
196
- # Title is str, unchanged
197
175
  assert result[0].title == "TLS Configuration" # type: ignore[union-attr]
198
- # Body paragraph: TLS footnoted
199
176
  body_children = result[0].children[0].children # type: ignore[union-attr]
200
177
  assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "TLS" for c in body_children)
201
178
 
@@ -203,28 +180,21 @@ def test_no_expand_in_admonition_title():
203
180
  def test_no_expand_in_code_block():
204
181
  abbrevs = {"SQL": "Structured Query Language"}
205
182
  code = CodeBlock(code="SELECT * FROM SQL_table", language="sql")
206
- nodes = (code,)
207
- result = apply_abbreviations(nodes, abbrevs, page_text="SELECT * FROM SQL_table")
208
- assert result[0].code == "SELECT * FROM SQL_table"
183
+ result = apply_abbreviations((code,), abbrevs, page_text="SELECT * FROM SQL_table")
184
+ assert result[0].code == "SELECT * FROM SQL_table" # type: ignore[union-attr]
209
185
 
210
186
 
211
187
  def test_no_expand_in_link_text():
212
188
  abbrevs = {"CLI": "Command Line Interface"}
213
- link = LinkNode(
214
- href="https://example.com",
215
- children=(TextNode("CLI tools"),),
216
- )
217
- para = Paragraph(children=(link,))
218
- result = apply_abbreviations((para,), abbrevs, page_text="CLI tools")
219
- link_text = result[0].children[0].children[0].text # type: ignore[union-attr]
220
- assert link_text == "CLI tools" # not expanded inside link
189
+ link = LinkNode(href="https://example.com", children=(TextNode("CLI tools"),))
190
+ result = apply_abbreviations((Paragraph(children=(link,)),), abbrevs, page_text="CLI tools")
191
+ assert result[0].children[0].children[0].text == "CLI tools" # type: ignore[union-attr]
221
192
 
222
193
 
223
194
  def test_expands_inside_bold():
224
195
  abbrevs = {"CI": "Continuous Integration"}
225
196
  bold = BoldNode(children=(TextNode("CI pipeline"),))
226
- para = Paragraph(children=(bold,))
227
- result = apply_abbreviations((para,), abbrevs, page_text="CI pipeline")
197
+ result = apply_abbreviations((Paragraph(children=(bold,)),), abbrevs, page_text="CI pipeline")
228
198
  bold_children = result[0].children[0].children # type: ignore[union-attr]
229
199
  assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "CI" for c in bold_children)
230
200
 
@@ -234,53 +204,33 @@ def test_word_boundary_not_partial_match():
234
204
  nodes = (_para("The RAPID response via API."),)
235
205
  result = apply_abbreviations(nodes, abbrevs, page_text="The RAPID response via API.")
236
206
  para = result[0]
237
- assert isinstance(para, Paragraph)
238
- # RAPID should be untouched; API should be footnoted
239
207
  all_text = "".join(c.text for c in para.children if isinstance(c, TextNode))
240
208
  assert "RAPID" in all_text
241
- footnotes = [c for c in para.children if isinstance(c, AbbrevFootnoteNode)]
242
- assert any(fn.abbr == "API" for fn in footnotes)
209
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "API" for c in para.children)
243
210
 
244
211
 
245
- def test_footnoted_abbrevs_not_in_glossary():
246
- # Abbreviations expanded inline via footnote should NOT also appear in a glossary.
212
+ def test_footnoted_abbrevs_not_in_extras():
247
213
  abbrevs = {"API": "Application Programming Interface", "IAM": "Identity and Access Management"}
248
214
  nodes = (_para("Use the API and IAM to authenticate."),)
249
- result = apply_abbreviations(
250
- nodes, abbrevs,
251
- page_text="Use the API and IAM to authenticate."
252
- )
253
- # Both footnoted inline no glossary
254
- assert len(result) == 1
255
- para = result[0]
256
- assert isinstance(para, Paragraph)
257
- footnotes = [c for c in para.children if isinstance(c, AbbrevFootnoteNode)]
258
- assert {fn.abbr for fn in footnotes} == {"API", "IAM"}
215
+ result = apply_abbreviations(nodes, abbrevs, page_text="Use the API and IAM to authenticate.")
216
+ assert len(result) == 2
217
+ glossary = result[1]
218
+ assert isinstance(glossary, AbbrevGlossaryBlock)
219
+ assert {fn.abbr for fn in glossary.footnoted} == {"API", "IAM"}
220
+ assert len(glossary.extras) == 0
259
221
 
260
222
 
261
223
  def test_abbrev_not_in_text_produces_no_glossary():
262
224
  abbrevs = {"XYZ": "Some Definition"}
263
- nodes = (_para("Nothing relevant here."),)
264
- result = apply_abbreviations(nodes, abbrevs, page_text="Nothing relevant here.")
265
- # XYZ never mentioned → no glossary
225
+ result = apply_abbreviations((_para("Nothing relevant here."),), abbrevs, page_text="Nothing relevant here.")
266
226
  assert len(result) == 1
267
227
 
268
228
 
269
- def test_glossary_entries_sorted_alphabetically():
229
+ def test_extras_sorted_alphabetically():
270
230
  abbrevs = {"RBAC": "Role-Based Access Control", "IAM": "Identity and Access Management"}
271
- # Both only in heading (unsafe), so both go to glossary
272
- section = Section(
273
- level=1,
274
- anchor="overview",
275
- title=(TextNode("IAM and RBAC Overview"),),
276
- children=(),
277
- )
278
- result = apply_abbreviations(
279
- (section,), abbrevs, page_text="IAM and RBAC Overview"
280
- )
231
+ section = Section(level=1, anchor="overview", title=(TextNode("IAM and RBAC Overview"),), children=())
232
+ result = apply_abbreviations((section,), abbrevs, page_text="IAM and RBAC Overview")
281
233
  glossary = result[-1]
282
- assert isinstance(glossary, Section)
283
- items = glossary.children[0].items # type: ignore[union-attr]
284
- labels = [item.children[0].children[0].text for item in items] # type: ignore[union-attr]
285
- assert labels == sorted(labels)
286
-
234
+ assert isinstance(glossary, AbbrevGlossaryBlock)
235
+ extra_abbrs = [abbr for abbr, _ in glossary.extras]
236
+ assert extra_abbrs == sorted(extra_abbrs)
@@ -402,6 +402,24 @@ class TestFootnoteEmitter:
402
402
  assert 'fn-1' in html_out
403
403
  assert 'My note.' in html_out
404
404
 
405
+ def test_abbrev_glossary_extras_in_ol_not_ul(self) -> None:
406
+ """Abbreviations only in headings/titles must appear in <ol>, not <ul>."""
407
+ from mkdocs_to_confluence.emitter.xhtml import emit
408
+ from mkdocs_to_confluence.ir.nodes import AbbrevFootnoteNode, AbbrevGlossaryBlock
409
+ fn = AbbrevFootnoteNode(abbr="API", definition="Application Programming Interface", number=1)
410
+ block = AbbrevGlossaryBlock(
411
+ footnoted=(fn,),
412
+ extras=(("AD", "Active Directory"),),
413
+ )
414
+ out = emit((block,))
415
+ assert "<ol>" in out
416
+ assert "<ul>" not in out
417
+ assert "AD" in out
418
+ assert "Active Directory" in out
419
+ assert "API" in out
420
+ # extras must NOT get an anchor macro
421
+ assert out.count('ac:name="anchor"') == 1 # only for the footnoted entry
422
+
405
423
 
406
424
  # ── Inline HTML emitters ──────────────────────────────────────────────────────
407
425