mkdocs2confluence 0.7.9__tar.gz → 0.7.10__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.9/src/mkdocs2confluence.egg-info → mkdocs2confluence-0.7.10}/PKG-INFO +3 -3
  2. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/README.md +2 -2
  3. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/pyproject.toml +1 -1
  4. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10/src/mkdocs2confluence.egg-info}/PKG-INFO +3 -3
  5. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/emitter/xhtml.py +15 -0
  6. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/ir/nodes.py +16 -0
  7. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/transforms/abbrevs.py +47 -26
  8. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_abbrevs.py +49 -46
  9. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/LICENSE +0 -0
  10. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/setup.cfg +0 -0
  11. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  12. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  13. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  14. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  15. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  16. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/__init__.py +0 -0
  17. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/cli.py +0 -0
  18. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  19. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  20. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/ir/document.py +0 -0
  21. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  22. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  23. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/loader/config.py +0 -0
  24. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  25. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  26. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/loader/page.py +0 -0
  27. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  28. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  29. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  30. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  31. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  32. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  33. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  34. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  35. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  36. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  37. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  38. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  39. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  40. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preview/render.py +0 -0
  41. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/preview/server.py +0 -0
  42. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  43. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  44. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  45. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  46. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  47. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  48. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  49. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  50. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  51. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_cli.py +0 -0
  52. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_editlink.py +0 -0
  53. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_emitter.py +0 -0
  54. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_extra_css.py +0 -0
  55. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_frontmatter.py +0 -0
  56. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_icons.py +0 -0
  57. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_images.py +0 -0
  58. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_internallinks.py +0 -0
  59. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_ir.py +0 -0
  60. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_linkdefs.py +0 -0
  61. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_loader.py +0 -0
  62. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_mermaid.py +0 -0
  63. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_page_loader.py +0 -0
  64. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_parser.py +0 -0
  65. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_pdf.py +0 -0
  66. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_preprocess.py +0 -0
  67. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_preview.py +0 -0
  68. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_publish_client.py +0 -0
  69. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_publish_config.py +0 -0
  70. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_publish_pipeline.py +0 -0
  71. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_server.py +0 -0
  72. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.10}/tests/test_treeutil.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs2confluence
3
- Version: 0.7.9
3
+ Version: 0.7.10
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
@@ -355,7 +355,7 @@ If `repo_url` + `edit_uri` are set in `mkdocs.yml`, an **Edit Source** row links
355
355
 
356
356
  ### Abbreviation expansion
357
357
 
358
- MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline Confluence has no native `<abbr>` tooltip. The **first occurrence** in body text is expanded as `IAM (Identity and Access Management)`; subsequent occurrences are left as-is. Abbreviations that only appear in headings or code are collected into an auto-appended **Glossary** section.
358
+ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are rendered as inline Confluence **footnotes**. The **first occurrence** of each abbreviation in body text is annotated with a footnote macro — Confluence renders it as a superscript number and collects all definitions at the bottom of the page. Subsequent occurrences are left as plain text. Abbreviations that only appear in headings or other non-expandable contexts are collected into an auto-appended **Glossary** section as a fallback.
359
359
 
360
360
  ---
361
361
 
@@ -364,7 +364,7 @@ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline — C
364
364
  | Feature | Behaviour |
365
365
  |---|---|
366
366
  | **Admonition styling** | `tip`, `info`, `warning`, `note` use Confluence's fixed native macro colours. `danger`, `error`, `bug` use a custom red `panel` macro with 🚨 prefix. All other types are mapped to the nearest native macro. |
367
- | **Abbreviation tooltips** | No native tooltip support. First occurrence expanded inline; remainder left as-is. |
367
+ | **Abbreviation tooltips** | No native tooltip support. First occurrence annotated with an inline Confluence footnote; definition collected at the bottom of the page. |
368
368
  | **Page ordering** | Confluence sorts child pages alphabetically; the v2 REST API has no write endpoint for ordering. |
369
369
  | **Code language aliases** | Short aliases (`py`, `js`, `yml`, `ts`, `sh`) are passed through as-is; Confluence requires full language names for syntax highlighting. |
370
370
  | **Unrecognised blocks** | Preserved as a visible `warning` macro — no content is silently lost. |
@@ -315,7 +315,7 @@ If `repo_url` + `edit_uri` are set in `mkdocs.yml`, an **Edit Source** row links
315
315
 
316
316
  ### Abbreviation expansion
317
317
 
318
- MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline Confluence has no native `<abbr>` tooltip. The **first occurrence** in body text is expanded as `IAM (Identity and Access Management)`; subsequent occurrences are left as-is. Abbreviations that only appear in headings or code are collected into an auto-appended **Glossary** section.
318
+ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are rendered as inline Confluence **footnotes**. The **first occurrence** of each abbreviation in body text is annotated with a footnote macro — Confluence renders it as a superscript number and collects all definitions at the bottom of the page. Subsequent occurrences are left as plain text. Abbreviations that only appear in headings or other non-expandable contexts are collected into an auto-appended **Glossary** section as a fallback.
319
319
 
320
320
  ---
321
321
 
@@ -324,7 +324,7 @@ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline — C
324
324
  | Feature | Behaviour |
325
325
  |---|---|
326
326
  | **Admonition styling** | `tip`, `info`, `warning`, `note` use Confluence's fixed native macro colours. `danger`, `error`, `bug` use a custom red `panel` macro with 🚨 prefix. All other types are mapped to the nearest native macro. |
327
- | **Abbreviation tooltips** | No native tooltip support. First occurrence expanded inline; remainder left as-is. |
327
+ | **Abbreviation tooltips** | No native tooltip support. First occurrence annotated with an inline Confluence footnote; definition collected at the bottom of the page. |
328
328
  | **Page ordering** | Confluence sorts child pages alphabetically; the v2 REST API has no write endpoint for ordering. |
329
329
  | **Code language aliases** | Short aliases (`py`, `js`, `yml`, `ts`, `sh`) are passed through as-is; Confluence requires full language names for syntax highlighting. |
330
330
  | **Unrecognised blocks** | Preserved as a visible `warning` macro — no content is silently lost. |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs2confluence"
3
- version = "0.7.9"
3
+ version = "0.7.10"
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.9
3
+ Version: 0.7.10
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
@@ -355,7 +355,7 @@ If `repo_url` + `edit_uri` are set in `mkdocs.yml`, an **Edit Source** row links
355
355
 
356
356
  ### Abbreviation expansion
357
357
 
358
- MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline Confluence has no native `<abbr>` tooltip. The **first occurrence** in body text is expanded as `IAM (Identity and Access Management)`; subsequent occurrences are left as-is. Abbreviations that only appear in headings or code are collected into an auto-appended **Glossary** section.
358
+ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are rendered as inline Confluence **footnotes**. The **first occurrence** of each abbreviation in body text is annotated with a footnote macro — Confluence renders it as a superscript number and collects all definitions at the bottom of the page. Subsequent occurrences are left as plain text. Abbreviations that only appear in headings or other non-expandable contexts are collected into an auto-appended **Glossary** section as a fallback.
359
359
 
360
360
  ---
361
361
 
@@ -364,7 +364,7 @@ MkDocs abbreviation definitions (`*[ABBR]: Full term`) are expanded inline — C
364
364
  | Feature | Behaviour |
365
365
  |---|---|
366
366
  | **Admonition styling** | `tip`, `info`, `warning`, `note` use Confluence's fixed native macro colours. `danger`, `error`, `bug` use a custom red `panel` macro with 🚨 prefix. All other types are mapped to the nearest native macro. |
367
- | **Abbreviation tooltips** | No native tooltip support. First occurrence expanded inline; remainder left as-is. |
367
+ | **Abbreviation tooltips** | No native tooltip support. First occurrence annotated with an inline Confluence footnote; definition collected at the bottom of the page. |
368
368
  | **Page ordering** | Confluence sorts child pages alphabetically; the v2 REST API has no write endpoint for ordering. |
369
369
  | **Code language aliases** | Short aliases (`py`, `js`, `yml`, `ts`, `sh`) are passed through as-is; Confluence requires full language names for syntax highlighting. |
370
370
  | **Unrecognised blocks** | Preserved as a visible `warning` macro — no content is silently lost. |
@@ -22,6 +22,7 @@ from typing import Sequence
22
22
  from urllib.parse import urlparse
23
23
 
24
24
  from mkdocs_to_confluence.ir.nodes import (
25
+ AbbrevFootnoteNode,
25
26
  Admonition,
26
27
  BlockQuote,
27
28
  BoldNode,
@@ -555,6 +556,18 @@ def _emit_footnote_ref(node: FootnoteRef) -> str:
555
556
  )
556
557
 
557
558
 
559
+ def _emit_abbrev_footnote(node: AbbrevFootnoteNode) -> str:
560
+ """Abbreviation term + inline Confluence footnote macro with its definition."""
561
+ term = html.escape(node.abbr)
562
+ defn = html.escape(node.definition)
563
+ return (
564
+ 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>"
568
+ )
569
+
570
+
558
571
  def _emit_footnote_block(node: FootnoteBlock) -> str:
559
572
  """Footnotes section: heading + ordered list with anchor targets."""
560
573
  items: list[str] = []
@@ -613,6 +626,8 @@ def _emit_inline(node: IRNode) -> str:
613
626
  return _emit_image(node)
614
627
  if isinstance(node, FootnoteRef):
615
628
  return _emit_footnote_ref(node)
629
+ if isinstance(node, AbbrevFootnoteNode):
630
+ return _emit_abbrev_footnote(node)
616
631
  if isinstance(node, LineBreakNode):
617
632
  return "<br />"
618
633
  if isinstance(node, InlineHtmlNode):
@@ -428,6 +428,22 @@ class FrontMatter(IRNode):
428
428
  site_url: str | None = None
429
429
 
430
430
 
431
+ # ── Abbreviation footnotes ────────────────────────────────────────────────────
432
+
433
+
434
+ @dataclass(frozen=True)
435
+ class AbbrevFootnoteNode(IRNode):
436
+ """An abbreviation annotated with an inline Confluence footnote.
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.
441
+ """
442
+
443
+ abbr: str
444
+ definition: str
445
+
446
+
431
447
  # ── Footnotes ────────────────────────────────────────────────────────────────
432
448
 
433
449
 
@@ -3,17 +3,19 @@
3
3
  Collects ``*[ABBR]: definition`` pairs (extracted by
4
4
  :mod:`mkdocs_to_confluence.preprocess.abbrevs`) and walks the IR tree to:
5
5
 
6
- 1. **Expand** the first occurrence of each abbreviation in *safe* body nodes —
7
- paragraphs, list items, table body cells, blockquotes — by rewriting it to
8
- ``ABBR (definition)``.
6
+ 1. **Annotate** the first occurrence of each abbreviation in *safe* body nodes —
7
+ paragraphs, list items, table body cells, blockquotes — by inserting an
8
+ inline Confluence ``footnote`` macro immediately after the term. Confluence
9
+ renders this as a superscript number and collects all definitions at the
10
+ bottom of the page.
9
11
 
10
12
  2. **Skip** structural/title nodes where expansion would look odd:
11
13
  section headings, table header cells, admonition/panel titles, code spans,
12
14
  and link text.
13
15
 
14
16
  3. **Append a Glossary section** at the end of the page for any abbreviation
15
- that was detected in the page text but could not be expanded inline because
16
- it only appeared in skipped contexts.
17
+ that was detected in the page text but could not be footnoted because it
18
+ only appeared in skipped contexts (headings, table headers, etc.).
17
19
 
18
20
  Entry point
19
21
  -----------
@@ -27,6 +29,7 @@ import re
27
29
  from dataclasses import replace
28
30
 
29
31
  from mkdocs_to_confluence.ir.nodes import (
32
+ AbbrevFootnoteNode,
30
33
  Admonition,
31
34
  BlockQuote,
32
35
  BoldNode,
@@ -64,21 +67,31 @@ class _State:
64
67
  for abbr in abbrevs
65
68
  }
66
69
 
67
- def expand_text(self, text: str) -> str:
68
- """Return *text* with first-occurrence abbreviations expanded.
70
+ def expand_to_nodes(self, text: str) -> tuple[IRNode, ...]:
71
+ """Split *text* around the first unexpanded abbreviation.
69
72
 
70
- Each abbreviation is expanded at most once across the entire page.
71
- Once an abbreviation has been expanded, subsequent occurrences are left
72
- as plain text.
73
+ Returns a mix of :class:`TextNode` and :class:`AbbrevFootnoteNode`.
74
+ Each abbreviation is footnoted at most once per page.
73
75
  """
74
- for abbr, defn in self.abbrevs.items():
76
+ best: tuple[int, int, str] | None = None
77
+ for abbr in self.abbrevs:
75
78
  if abbr in self.expanded:
76
79
  continue
77
- pattern = self._patterns[abbr]
78
- if pattern.search(text):
79
- text = pattern.sub(f"{abbr} ({defn})", text, count=1)
80
- self.expanded.add(abbr)
81
- return text
80
+ m = self._patterns[abbr].search(text)
81
+ if m and (best is None or m.start() < best[0]):
82
+ best = (m.start(), m.end(), abbr)
83
+
84
+ if best is None:
85
+ return (TextNode(text),) if text else ()
86
+
87
+ start, end, abbr = best
88
+ self.expanded.add(abbr)
89
+ nodes: list[IRNode] = []
90
+ if text[:start]:
91
+ nodes.append(TextNode(text[:start]))
92
+ nodes.append(AbbrevFootnoteNode(abbr=abbr, definition=self.abbrevs[abbr]))
93
+ nodes.extend(self.expand_to_nodes(text[end:]))
94
+ return tuple(nodes)
82
95
 
83
96
 
84
97
  # ── Block-level transform ─────────────────────────────────────────────────────
@@ -151,22 +164,25 @@ def _transform_table_cell(cell: TableCell, state: _State) -> TableCell:
151
164
 
152
165
 
153
166
  def _inline(nodes: tuple[IRNode, ...], state: _State, safe: bool) -> tuple[IRNode, ...]:
154
- return tuple(_transform_inline(n, state, safe) for n in nodes)
167
+ result: list[IRNode] = []
168
+ for n in nodes:
169
+ result.extend(_transform_inline(n, state, safe))
170
+ return tuple(result)
155
171
 
156
172
 
157
- def _transform_inline(node: IRNode, state: _State, safe: bool) -> IRNode:
173
+ def _transform_inline(node: IRNode, state: _State, safe: bool) -> tuple[IRNode, ...]:
158
174
  if isinstance(node, TextNode):
159
- return TextNode(state.expand_text(node.text)) if safe else node
175
+ return state.expand_to_nodes(node.text) if safe else (node,)
160
176
 
161
177
  if isinstance(node, (BoldNode, ItalicNode, StrikethroughNode)):
162
- return replace(node, children=_inline(node.children, state, safe))
178
+ return (replace(node, children=_inline(node.children, state, safe)),)
163
179
 
164
180
  if isinstance(node, LinkNode):
165
181
  # Expanding inside link text could break the anchor label — skip.
166
- return replace(node, children=_inline(node.children, state, safe=False))
182
+ return (replace(node, children=_inline(node.children, state, safe=False)),)
167
183
 
168
184
  # CodeInlineNode, ImageNode — never expand.
169
- return node
185
+ return (node,)
170
186
 
171
187
 
172
188
  # ── Glossary builder ──────────────────────────────────────────────────────────
@@ -182,7 +198,7 @@ def _find_mentioned(text: str, abbrevs: dict[str, str]) -> set[str]:
182
198
 
183
199
 
184
200
  def _build_glossary_section(terms: dict[str, str]) -> tuple[IRNode, ...]:
185
- """Return an HR + h6 ``Section`` listing abbreviations that could not be expanded inline."""
201
+ """Return an HR + h6 ``Section`` listing abbreviations that could not be footnoted."""
186
202
  items = tuple(
187
203
  ListItem(children=(Paragraph(children=(TextNode(f"{abbr} — {defn}"),)),))
188
204
  for abbr, defn in sorted(terms.items())
@@ -217,9 +233,11 @@ def apply_abbreviations(
217
233
  abbreviations need a glossary entry.
218
234
 
219
235
  Returns:
220
- Modified node tuple. A ``Glossary`` section is appended if any
221
- abbreviation was detected in *page_text* but could not be expanded
222
- inline (e.g. it only appeared in headings or table headers).
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).
223
241
  """
224
242
  if not abbrevs:
225
243
  return nodes
@@ -228,12 +246,15 @@ def apply_abbreviations(
228
246
  transformed = tuple(_transform_block(n, state) for n in nodes)
229
247
 
230
248
  mentioned = _find_mentioned(page_text, abbrevs)
249
+ # Only add a glossary entry for abbreviations that were never footnoted.
231
250
  glossary_needed = {
232
251
  abbr: abbrevs[abbr]
233
252
  for abbr in mentioned
253
+ if abbr not in state.expanded
234
254
  }
235
255
 
236
256
  if glossary_needed:
237
257
  transformed = transformed + _build_glossary_section(glossary_needed)
238
258
 
239
259
  return transformed
260
+
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from mkdocs_to_confluence.ir.nodes import (
6
+ AbbrevFootnoteNode,
6
7
  Admonition,
7
8
  BoldNode,
8
9
  BulletList,
@@ -81,26 +82,36 @@ def _para(text: str) -> Paragraph:
81
82
  return Paragraph(children=(TextNode(text),))
82
83
 
83
84
 
84
- def test_expands_first_occurrence_in_paragraph():
85
+ def test_expands_first_occurrence_as_footnote():
85
86
  abbrevs = {"IAM": "Identity and Access Management"}
86
87
  nodes = (_para("The IAM platform handles IAM requests."),)
87
88
  result = apply_abbreviations(nodes, abbrevs, page_text="The IAM platform handles IAM requests.")
88
89
  para = result[0]
89
90
  assert isinstance(para, Paragraph)
90
- text = para.children[0].text # type: ignore[union-attr]
91
- assert "IAM (Identity and Access Management)" in text
92
- # Second occurrence not expanded
93
- assert text.count("IAM (Identity and Access Management)") == 1
94
- assert text.endswith("IAM requests.")
91
+ children = para.children
92
+ # Should be: TextNode("The ") + AbbrevFootnoteNode + TextNode(" platform handles IAM requests.")
93
+ 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"
99
+ # Second occurrence left as plain text
100
+ assert isinstance(children[2], TextNode)
101
+ assert "IAM requests." in children[2].text
102
+ assert "Identity and Access Management" not in children[2].text
95
103
 
96
104
 
97
105
  def test_expands_multiple_different_abbrevs():
98
106
  abbrevs = {"IAM": "Identity and Access Management", "RBAC": "Role-Based Access Control"}
99
107
  nodes = (_para("Use IAM and RBAC for access control."),)
100
108
  result = apply_abbreviations(nodes, abbrevs, page_text="Use IAM and RBAC for access control.")
101
- text = result[0].children[0].text # type: ignore[union-attr]
102
- assert "IAM (Identity and Access Management)" in text
103
- assert "RBAC (Role-Based Access Control)" in text
109
+ para = result[0]
110
+ assert isinstance(para, Paragraph)
111
+ footnotes = [c for c in para.children if isinstance(c, AbbrevFootnoteNode)]
112
+ abbrs = {fn.abbr for fn in footnotes}
113
+ assert "IAM" in abbrs
114
+ assert "RBAC" in abbrs
104
115
 
105
116
 
106
117
  def test_no_expand_when_no_abbrevs():
@@ -131,14 +142,13 @@ def test_glossary_appended_for_heading_only_abbrev():
131
142
  children=(),
132
143
  )
133
144
  result = apply_abbreviations((section,), abbrevs, page_text="IAM Platform")
134
- # An HR separator and a Glossary section should be appended
145
+ # IAM only in heading (unsafe) no footnote glossary fallback appended
135
146
  assert len(result) == 3
136
147
  assert isinstance(result[1], HorizontalRule)
137
148
  glossary = result[2]
138
149
  assert isinstance(glossary, Section)
139
150
  assert glossary.anchor == "glossary"
140
151
  assert glossary.level == 6
141
- # Glossary should list the term
142
152
  bullet_list = glossary.children[0]
143
153
  assert isinstance(bullet_list, BulletList)
144
154
  item_text = bullet_list.items[0].children[0].children[0].text # type: ignore[union-attr]
@@ -146,16 +156,13 @@ def test_glossary_appended_for_heading_only_abbrev():
146
156
  assert "Identity and Access Management" in item_text
147
157
 
148
158
 
149
- def test_no_glossary_when_abbrev_expanded_inline():
159
+ def test_no_glossary_when_abbrev_footnoted_inline():
150
160
  abbrevs = {"IAM": "Identity and Access Management"}
151
161
  nodes = (_para("The IAM platform."),)
152
162
  result = apply_abbreviations(nodes, abbrevs, page_text="The IAM platform.")
153
- # Expanded inline, but also added to glossary for bottom-of-page reference
154
- assert len(result) == 3
155
- assert isinstance(result[1], HorizontalRule)
156
- glossary = result[-1]
157
- assert isinstance(glossary, Section)
158
- assert glossary.anchor == "glossary"
163
+ # Footnoted inline no glossary needed
164
+ assert len(result) == 1
165
+ assert isinstance(result[0], Paragraph)
159
166
 
160
167
 
161
168
  def test_no_expand_in_table_header_cell():
@@ -173,9 +180,9 @@ def test_no_expand_in_table_header_cell():
173
180
  header_text = result[0].header.cells[0].children[0].text # type: ignore[union-attr]
174
181
  assert header_text == "API Endpoint"
175
182
 
176
- # Body cell: first occurrence expanded
177
- body_text = result[0].rows[0].cells[0].children[0].text # type: ignore[union-attr]
178
- assert "API (Application Programming Interface)" in body_text
183
+ # Body cell: first occurrence footnoted
184
+ body_children = result[0].rows[0].cells[0].children # type: ignore[union-attr]
185
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "API" for c in body_children)
179
186
 
180
187
 
181
188
  def test_no_expand_in_admonition_title():
@@ -188,9 +195,9 @@ def test_no_expand_in_admonition_title():
188
195
  result = apply_abbreviations((admonition,), abbrevs, page_text="TLS Configuration Use TLS for encryption.")
189
196
  # Title is str, unchanged
190
197
  assert result[0].title == "TLS Configuration" # type: ignore[union-attr]
191
- # Body paragraph: expanded
192
- body_text = result[0].children[0].children[0].text # type: ignore[union-attr]
193
- assert "TLS (Transport Layer Security)" in body_text
198
+ # Body paragraph: TLS footnoted
199
+ body_children = result[0].children[0].children # type: ignore[union-attr]
200
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "TLS" for c in body_children)
194
201
 
195
202
 
196
203
  def test_no_expand_in_code_block():
@@ -218,42 +225,37 @@ def test_expands_inside_bold():
218
225
  bold = BoldNode(children=(TextNode("CI pipeline"),))
219
226
  para = Paragraph(children=(bold,))
220
227
  result = apply_abbreviations((para,), abbrevs, page_text="CI pipeline")
221
- bold_text = result[0].children[0].children[0].text # type: ignore[union-attr]
222
- assert "CI (Continuous Integration)" in bold_text
228
+ bold_children = result[0].children[0].children # type: ignore[union-attr]
229
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "CI" for c in bold_children)
223
230
 
224
231
 
225
232
  def test_word_boundary_not_partial_match():
226
233
  abbrevs = {"API": "Application Programming Interface"}
227
234
  nodes = (_para("The RAPID response via API."),)
228
235
  result = apply_abbreviations(nodes, abbrevs, page_text="The RAPID response via API.")
229
- text = result[0].children[0].text # type: ignore[union-attr]
230
- # RAPID should not be touched; API should be expanded
231
- assert "RAPID" in text
232
- assert "API (Application Programming Interface)" in text
236
+ para = result[0]
237
+ assert isinstance(para, Paragraph)
238
+ # RAPID should be untouched; API should be footnoted
239
+ all_text = "".join(c.text for c in para.children if isinstance(c, TextNode))
240
+ 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)
233
243
 
234
244
 
235
- def test_glossary_includes_inline_expanded_abbrevs():
236
- # Abbreviations expanded inline should ALSO appear in the glossary,
237
- # so readers who jump directly to the bottom have a full reference.
245
+ def test_footnoted_abbrevs_not_in_glossary():
246
+ # Abbreviations expanded inline via footnote should NOT also appear in a glossary.
238
247
  abbrevs = {"API": "Application Programming Interface", "IAM": "Identity and Access Management"}
239
248
  nodes = (_para("Use the API and IAM to authenticate."),)
240
249
  result = apply_abbreviations(
241
250
  nodes, abbrevs,
242
251
  page_text="Use the API and IAM to authenticate."
243
252
  )
244
- # Both should be expanded inline...
245
- body_text = result[0].children[0].text # type: ignore[union-attr]
246
- assert "API (Application Programming Interface)" in body_text
247
- assert "IAM (Identity and Access Management)" in body_text
248
- # ...AND both should appear in the glossary at the bottom.
249
- assert len(result) == 3
250
- assert isinstance(result[1], HorizontalRule)
251
- glossary = result[-1]
252
- assert isinstance(glossary, Section)
253
- items = glossary.children[0].items # type: ignore[union-attr]
254
- labels = " ".join(item.children[0].children[0].text for item in items) # type: ignore[union-attr]
255
- assert "API" in labels
256
- assert "IAM" in labels
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"}
257
259
 
258
260
 
259
261
  def test_abbrev_not_in_text_produces_no_glossary():
@@ -281,3 +283,4 @@ def test_glossary_entries_sorted_alphabetically():
281
283
  items = glossary.children[0].items # type: ignore[union-attr]
282
284
  labels = [item.children[0].children[0].text for item in items] # type: ignore[union-attr]
283
285
  assert labels == sorted(labels)
286
+