mkdocs2confluence 0.7.9__tar.gz → 0.7.11__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.11}/PKG-INFO +3 -3
  2. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/README.md +2 -2
  3. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/pyproject.toml +1 -1
  4. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11/src/mkdocs2confluence.egg-info}/PKG-INFO +3 -3
  5. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/emitter/xhtml.py +45 -0
  6. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/ir/nodes.py +34 -0
  7. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/transforms/abbrevs.py +66 -51
  8. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_abbrevs.py +75 -122
  9. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/LICENSE +0 -0
  10. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/setup.cfg +0 -0
  11. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs2confluence.egg-info/SOURCES.txt +0 -0
  12. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs2confluence.egg-info/dependency_links.txt +0 -0
  13. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs2confluence.egg-info/entry_points.txt +0 -0
  14. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs2confluence.egg-info/requires.txt +0 -0
  15. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs2confluence.egg-info/top_level.txt +0 -0
  16. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/__init__.py +0 -0
  17. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/cli.py +0 -0
  18. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/emitter/__init__.py +0 -0
  19. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/ir/__init__.py +0 -0
  20. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/ir/document.py +0 -0
  21. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/ir/treeutil.py +0 -0
  22. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/loader/__init__.py +0 -0
  23. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/loader/config.py +0 -0
  24. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/loader/extra_css.py +0 -0
  25. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/loader/nav.py +0 -0
  26. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/loader/page.py +0 -0
  27. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/parser/__init__.py +0 -0
  28. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/parser/markdown.py +0 -0
  29. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/pdf/__init__.py +0 -0
  30. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/pdf/generator.py +0 -0
  31. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/pdf/render.py +0 -0
  32. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preprocess/__init__.py +0 -0
  33. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preprocess/abbrevs.py +0 -0
  34. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preprocess/fence.py +0 -0
  35. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preprocess/frontmatter.py +0 -0
  36. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preprocess/icons.py +0 -0
  37. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preprocess/includes.py +0 -0
  38. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preprocess/linkdefs.py +0 -0
  39. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preview/__init__.py +0 -0
  40. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preview/render.py +0 -0
  41. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/preview/server.py +0 -0
  42. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/publisher/__init__.py +0 -0
  43. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/publisher/client.py +0 -0
  44. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/publisher/pipeline.py +0 -0
  45. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/transforms/__init__.py +0 -0
  46. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/transforms/assets.py +0 -0
  47. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/transforms/editlink.py +0 -0
  48. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/transforms/images.py +0 -0
  49. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/transforms/internallinks.py +0 -0
  50. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/src/mkdocs_to_confluence/transforms/mermaid.py +0 -0
  51. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_cli.py +0 -0
  52. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_editlink.py +0 -0
  53. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_emitter.py +0 -0
  54. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_extra_css.py +0 -0
  55. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_frontmatter.py +0 -0
  56. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_icons.py +0 -0
  57. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_images.py +0 -0
  58. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_internallinks.py +0 -0
  59. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_ir.py +0 -0
  60. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_linkdefs.py +0 -0
  61. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_loader.py +0 -0
  62. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_mermaid.py +0 -0
  63. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_page_loader.py +0 -0
  64. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_parser.py +0 -0
  65. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_pdf.py +0 -0
  66. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_preprocess.py +0 -0
  67. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_preview.py +0 -0
  68. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_publish_client.py +0 -0
  69. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_publish_config.py +0 -0
  70. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_publish_pipeline.py +0 -0
  71. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/tests/test_server.py +0 -0
  72. {mkdocs2confluence-0.7.9 → mkdocs2confluence-0.7.11}/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.11
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.11"
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.11
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,8 @@ from typing import Sequence
22
22
  from urllib.parse import urlparse
23
23
 
24
24
  from mkdocs_to_confluence.ir.nodes import (
25
+ AbbrevFootnoteNode,
26
+ AbbrevGlossaryBlock,
25
27
  Admonition,
26
28
  BlockQuote,
27
29
  BoldNode,
@@ -190,6 +192,8 @@ def _emit_node(node: IRNode) -> str:
190
192
  return _emit_front_matter(node)
191
193
  if isinstance(node, FootnoteBlock):
192
194
  return _emit_footnote_block(node)
195
+ if isinstance(node, AbbrevGlossaryBlock):
196
+ return _emit_abbrev_glossary_block(node)
193
197
  if isinstance(node, GridCards):
194
198
  return _emit_grid_cards(node)
195
199
  if isinstance(node, UnsupportedBlock):
@@ -555,6 +559,45 @@ def _emit_footnote_ref(node: FootnoteRef) -> str:
555
559
  )
556
560
 
557
561
 
562
+ def _emit_abbrev_footnote(node: AbbrevFootnoteNode) -> str:
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))
566
+ term = html.escape(node.abbr)
567
+ return (
568
+ f"{term}"
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>"
574
+ )
575
+
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"]
580
+ if node.footnoted:
581
+ parts.append("<ol>\n")
582
+ for fn in node.footnoted:
583
+ anchor = html.escape(f"abbr-{fn.number}")
584
+ anchor_macro = (
585
+ f'<ac:structured-macro ac:name="anchor">'
586
+ f'<ac:parameter ac:name=""><![CDATA[{anchor}]]></ac:parameter>'
587
+ f"</ac:structured-macro>"
588
+ )
589
+ abbr = html.escape(fn.abbr)
590
+ defn = html.escape(fn.definition)
591
+ parts.append(f"<li>{anchor_macro}<strong>{abbr}</strong> — {defn}</li>\n")
592
+ parts.append("</ol>\n")
593
+ if node.extras:
594
+ parts.append("<ul>\n")
595
+ for abbr, defn in node.extras:
596
+ parts.append(f"<li><strong>{html.escape(abbr)}</strong> — {html.escape(defn)}</li>\n")
597
+ parts.append("</ul>\n")
598
+ return "".join(parts)
599
+
600
+
558
601
  def _emit_footnote_block(node: FootnoteBlock) -> str:
559
602
  """Footnotes section: heading + ordered list with anchor targets."""
560
603
  items: list[str] = []
@@ -613,6 +656,8 @@ def _emit_inline(node: IRNode) -> str:
613
656
  return _emit_image(node)
614
657
  if isinstance(node, FootnoteRef):
615
658
  return _emit_footnote_ref(node)
659
+ if isinstance(node, AbbrevFootnoteNode):
660
+ return _emit_abbrev_footnote(node)
616
661
  if isinstance(node, LineBreakNode):
617
662
  return "<br />"
618
663
  if isinstance(node, InlineHtmlNode):
@@ -428,6 +428,40 @@ 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
+ """Inline: abbreviated term with a superscript anchor-link to the glossary.
437
+
438
+ The emitter renders ``ABBR<sup>[N]</sup>`` where ``[N]`` links to the
439
+ corresponding entry in the end-of-page :class:`AbbrevGlossaryBlock`.
440
+ """
441
+
442
+ abbr: str
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], ...]
463
+
464
+
431
465
  # ── Footnotes ────────────────────────────────────────────────────────────────
432
466
 
433
467
 
@@ -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,13 +29,14 @@ import re
27
29
  from dataclasses import replace
28
30
 
29
31
  from mkdocs_to_confluence.ir.nodes import (
32
+ AbbrevFootnoteNode,
33
+ AbbrevGlossaryBlock,
30
34
  Admonition,
31
35
  BlockQuote,
32
36
  BoldNode,
33
37
  BulletList,
34
38
  ContentTabs,
35
39
  Expandable,
36
- HorizontalRule,
37
40
  IRNode,
38
41
  ItalicNode,
39
42
  LinkNode,
@@ -57,28 +60,45 @@ class _State:
57
60
 
58
61
  def __init__(self, abbrevs: dict[str, str]) -> None:
59
62
  self.abbrevs = abbrevs
60
- 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
61
65
  # Pre-compile word-boundary patterns once.
62
66
  self._patterns: dict[str, re.Pattern[str]] = {
63
67
  abbr: re.compile(r"\b" + re.escape(abbr) + r"\b")
64
68
  for abbr in abbrevs
65
69
  }
66
70
 
67
- def expand_text(self, text: str) -> str:
68
- """Return *text* with first-occurrence abbreviations expanded.
71
+ @property
72
+ def expanded(self) -> set[str]:
73
+ return self._expanded_set
69
74
 
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.
75
+ def expand_to_nodes(self, text: str) -> tuple[IRNode, ...]:
76
+ """Split *text* around the first unexpanded abbreviation.
77
+
78
+ Returns a mix of :class:`TextNode` and :class:`AbbrevFootnoteNode`.
79
+ Each abbreviation is footnoted at most once per page.
73
80
  """
74
- for abbr, defn in self.abbrevs.items():
75
- if abbr in self.expanded:
81
+ best: tuple[int, int, str] | None = None
82
+ for abbr in self.abbrevs:
83
+ if abbr in self._expanded_set:
76
84
  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
85
+ m = self._patterns[abbr].search(text)
86
+ if m and (best is None or m.start() < best[0]):
87
+ best = (m.start(), m.end(), abbr)
88
+
89
+ if best is None:
90
+ return (TextNode(text),) if text else ()
91
+
92
+ start, end, abbr = best
93
+ self._expanded_list.append(abbr)
94
+ self._expanded_set.add(abbr)
95
+ number = len(self._expanded_list) # 1-based
96
+ nodes: list[IRNode] = []
97
+ if text[:start]:
98
+ nodes.append(TextNode(text[:start]))
99
+ nodes.append(AbbrevFootnoteNode(abbr=abbr, definition=self.abbrevs[abbr], number=number))
100
+ nodes.extend(self.expand_to_nodes(text[end:]))
101
+ return tuple(nodes)
82
102
 
83
103
 
84
104
  # ── Block-level transform ─────────────────────────────────────────────────────
@@ -151,22 +171,25 @@ def _transform_table_cell(cell: TableCell, state: _State) -> TableCell:
151
171
 
152
172
 
153
173
  def _inline(nodes: tuple[IRNode, ...], state: _State, safe: bool) -> tuple[IRNode, ...]:
154
- return tuple(_transform_inline(n, state, safe) for n in nodes)
174
+ result: list[IRNode] = []
175
+ for n in nodes:
176
+ result.extend(_transform_inline(n, state, safe))
177
+ return tuple(result)
155
178
 
156
179
 
157
- def _transform_inline(node: IRNode, state: _State, safe: bool) -> IRNode:
180
+ def _transform_inline(node: IRNode, state: _State, safe: bool) -> tuple[IRNode, ...]:
158
181
  if isinstance(node, TextNode):
159
- return TextNode(state.expand_text(node.text)) if safe else node
182
+ return state.expand_to_nodes(node.text) if safe else (node,)
160
183
 
161
184
  if isinstance(node, (BoldNode, ItalicNode, StrikethroughNode)):
162
- return replace(node, children=_inline(node.children, state, safe))
185
+ return (replace(node, children=_inline(node.children, state, safe)),)
163
186
 
164
187
  if isinstance(node, LinkNode):
165
188
  # Expanding inside link text could break the anchor label — skip.
166
- return replace(node, children=_inline(node.children, state, safe=False))
189
+ return (replace(node, children=_inline(node.children, state, safe=False)),)
167
190
 
168
191
  # CodeInlineNode, ImageNode — never expand.
169
- return node
192
+ return (node,)
170
193
 
171
194
 
172
195
  # ── Glossary builder ──────────────────────────────────────────────────────────
@@ -181,21 +204,6 @@ def _find_mentioned(text: str, abbrevs: dict[str, str]) -> set[str]:
181
204
  }
182
205
 
183
206
 
184
- def _build_glossary_section(terms: dict[str, str]) -> tuple[IRNode, ...]:
185
- """Return an HR + h6 ``Section`` listing abbreviations that could not be expanded inline."""
186
- items = tuple(
187
- ListItem(children=(Paragraph(children=(TextNode(f"{abbr} — {defn}"),)),))
188
- for abbr, defn in sorted(terms.items())
189
- )
190
- section = Section(
191
- level=6,
192
- anchor="glossary",
193
- title=(TextNode("Glossary"),),
194
- children=(BulletList(items=items),),
195
- )
196
- return (HorizontalRule(), section)
197
-
198
-
199
207
  # ── Public API ────────────────────────────────────────────────────────────────
200
208
 
201
209
 
@@ -213,13 +221,15 @@ def apply_abbreviations(
213
221
  :func:`~mkdocs_to_confluence.preprocess.abbrevs.extract_abbreviations`.
214
222
  page_text: The preprocessed page text (after stripping abbreviation
215
223
  definition lines) used to detect which abbreviations are
216
- actually present on the page. Used to determine which
217
- abbreviations need a glossary entry.
224
+ actually present on the page.
218
225
 
219
226
  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).
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.
223
233
  """
224
234
  if not abbrevs:
225
235
  return nodes
@@ -228,12 +238,17 @@ def apply_abbreviations(
228
238
  transformed = tuple(_transform_block(n, state) for n in nodes)
229
239
 
230
240
  mentioned = _find_mentioned(page_text, abbrevs)
231
- glossary_needed = {
232
- abbr: abbrevs[abbr]
233
- for abbr in mentioned
234
- }
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
+ )
235
249
 
236
- if glossary_needed:
237
- transformed = transformed + _build_glossary_section(glossary_needed)
250
+ if footnoted or extras:
251
+ transformed = transformed + (AbbrevGlossaryBlock(footnoted=footnoted, extras=extras),)
238
252
 
239
253
  return transformed
254
+
@@ -3,11 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from mkdocs_to_confluence.ir.nodes import (
6
+ AbbrevFootnoteNode,
7
+ AbbrevGlossaryBlock,
6
8
  Admonition,
7
9
  BoldNode,
8
- BulletList,
9
10
  CodeBlock,
10
- HorizontalRule,
11
11
  LinkNode,
12
12
  Paragraph,
13
13
  Section,
@@ -81,26 +81,42 @@ def _para(text: str) -> Paragraph:
81
81
  return Paragraph(children=(TextNode(text),))
82
82
 
83
83
 
84
- def test_expands_first_occurrence_in_paragraph():
84
+ def test_expands_first_occurrence_as_footnote():
85
85
  abbrevs = {"IAM": "Identity and Access Management"}
86
86
  nodes = (_para("The IAM platform handles IAM requests."),)
87
87
  result = apply_abbreviations(nodes, abbrevs, page_text="The IAM platform handles IAM requests.")
88
88
  para = result[0]
89
89
  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.")
90
+ children = para.children
91
+ # TextNode("The ") + AbbrevFootnoteNode + TextNode(" platform handles IAM requests.")
92
+ assert len(children) == 3
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
+ # Second occurrence left as plain text
100
+ assert isinstance(children[2], TextNode)
101
+ assert "IAM requests." 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
95
107
 
96
108
 
97
109
  def test_expands_multiple_different_abbrevs():
98
110
  abbrevs = {"IAM": "Identity and Access Management", "RBAC": "Role-Based Access Control"}
99
111
  nodes = (_para("Use IAM and RBAC for access control."),)
100
112
  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
113
+ para = result[0]
114
+ footnotes = [c for c in para.children if isinstance(c, AbbrevFootnoteNode)]
115
+ abbrs = {fn.abbr for fn in footnotes}
116
+ assert "IAM" in abbrs
117
+ assert "RBAC" in abbrs
118
+ numbers = sorted(fn.number for fn in footnotes)
119
+ assert numbers == [1, 2]
104
120
 
105
121
 
106
122
  def test_no_expand_when_no_abbrevs():
@@ -111,173 +127,110 @@ def test_no_expand_when_no_abbrevs():
111
127
 
112
128
  def test_no_expand_in_section_heading():
113
129
  abbrevs = {"IAM": "Identity and Access Management"}
114
- section = Section(
115
- level=2,
116
- anchor="iam",
117
- title=(TextNode("IAM Platform"),),
118
- children=(),
119
- )
130
+ section = Section(level=2, anchor="iam", title=(TextNode("IAM Platform"),), children=())
120
131
  result = apply_abbreviations((section,), abbrevs, page_text="IAM Platform")
121
132
  heading_text = result[0].title[0].text # type: ignore[union-attr]
122
- assert heading_text == "IAM Platform" # not expanded
133
+ assert heading_text == "IAM Platform" # not annotated
123
134
 
124
135
 
125
- def test_glossary_appended_for_heading_only_abbrev():
136
+ def test_glossary_block_appended_for_heading_only_abbrev():
126
137
  abbrevs = {"IAM": "Identity and Access Management"}
127
- section = Section(
128
- level=2,
129
- anchor="iam",
130
- title=(TextNode("IAM Platform"),),
131
- children=(),
132
- )
138
+ section = Section(level=2, anchor="iam", title=(TextNode("IAM Platform"),), children=())
133
139
  result = apply_abbreviations((section,), abbrevs, page_text="IAM Platform")
134
- # An HR separator and a Glossary section should be appended
135
- assert len(result) == 3
136
- assert isinstance(result[1], HorizontalRule)
137
- glossary = result[2]
138
- assert isinstance(glossary, Section)
139
- assert glossary.anchor == "glossary"
140
- assert glossary.level == 6
141
- # Glossary should list the term
142
- bullet_list = glossary.children[0]
143
- assert isinstance(bullet_list, BulletList)
144
- item_text = bullet_list.items[0].children[0].children[0].text # type: ignore[union-attr]
145
- assert "IAM" in item_text
146
- assert "Identity and Access Management" in item_text
147
-
148
-
149
- def test_no_glossary_when_abbrev_expanded_inline():
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"),)
145
+
146
+
147
+ def test_no_glossary_when_abbrev_footnoted_inline():
150
148
  abbrevs = {"IAM": "Identity and Access Management"}
151
149
  nodes = (_para("The IAM platform."),)
152
150
  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"
151
+ assert len(result) == 2
152
+ assert isinstance(result[1], AbbrevGlossaryBlock)
153
+ assert len(result[1].extras) == 0
159
154
 
160
155
 
161
156
  def test_no_expand_in_table_header_cell():
162
157
  abbrevs = {"API": "Application Programming Interface"}
163
- header = TableRow(cells=(
164
- TableCell(children=(TextNode("API Endpoint"),), is_header=True),
165
- ))
166
- body = TableRow(cells=(
167
- TableCell(children=(TextNode("The API docs"),), is_header=False),
168
- ))
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),))
169
160
  table = Table(header=header, rows=(body,))
170
161
  result = apply_abbreviations((table,), abbrevs, page_text="API Endpoint The API docs")
171
-
172
- # Header cell: not expanded
173
162
  header_text = result[0].header.cells[0].children[0].text # type: ignore[union-attr]
174
163
  assert header_text == "API Endpoint"
175
-
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
164
+ body_children = result[0].rows[0].cells[0].children # type: ignore[union-attr]
165
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "API" for c in body_children)
179
166
 
180
167
 
181
168
  def test_no_expand_in_admonition_title():
182
169
  abbrevs = {"TLS": "Transport Layer Security"}
183
170
  admonition = Admonition(
184
- kind="note",
185
- title="TLS Configuration",
171
+ kind="note", title="TLS Configuration",
186
172
  children=(_para("Use TLS for encryption."),),
187
173
  )
188
174
  result = apply_abbreviations((admonition,), abbrevs, page_text="TLS Configuration Use TLS for encryption.")
189
- # Title is str, unchanged
190
175
  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
176
+ body_children = result[0].children[0].children # type: ignore[union-attr]
177
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "TLS" for c in body_children)
194
178
 
195
179
 
196
180
  def test_no_expand_in_code_block():
197
181
  abbrevs = {"SQL": "Structured Query Language"}
198
182
  code = CodeBlock(code="SELECT * FROM SQL_table", language="sql")
199
- nodes = (code,)
200
- result = apply_abbreviations(nodes, abbrevs, page_text="SELECT * FROM SQL_table")
201
- 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]
202
185
 
203
186
 
204
187
  def test_no_expand_in_link_text():
205
188
  abbrevs = {"CLI": "Command Line Interface"}
206
- link = LinkNode(
207
- href="https://example.com",
208
- children=(TextNode("CLI tools"),),
209
- )
210
- para = Paragraph(children=(link,))
211
- result = apply_abbreviations((para,), abbrevs, page_text="CLI tools")
212
- link_text = result[0].children[0].children[0].text # type: ignore[union-attr]
213
- 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]
214
192
 
215
193
 
216
194
  def test_expands_inside_bold():
217
195
  abbrevs = {"CI": "Continuous Integration"}
218
196
  bold = BoldNode(children=(TextNode("CI pipeline"),))
219
- para = Paragraph(children=(bold,))
220
- 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
197
+ result = apply_abbreviations((Paragraph(children=(bold,)),), abbrevs, page_text="CI pipeline")
198
+ bold_children = result[0].children[0].children # type: ignore[union-attr]
199
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "CI" for c in bold_children)
223
200
 
224
201
 
225
202
  def test_word_boundary_not_partial_match():
226
203
  abbrevs = {"API": "Application Programming Interface"}
227
204
  nodes = (_para("The RAPID response via API."),)
228
205
  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
206
+ para = result[0]
207
+ all_text = "".join(c.text for c in para.children if isinstance(c, TextNode))
208
+ assert "RAPID" in all_text
209
+ assert any(isinstance(c, AbbrevFootnoteNode) and c.abbr == "API" for c in para.children)
233
210
 
234
211
 
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.
212
+ def test_footnoted_abbrevs_not_in_extras():
238
213
  abbrevs = {"API": "Application Programming Interface", "IAM": "Identity and Access Management"}
239
214
  nodes = (_para("Use the API and IAM to authenticate."),)
240
- result = apply_abbreviations(
241
- nodes, abbrevs,
242
- page_text="Use the API and IAM to authenticate."
243
- )
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
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
257
221
 
258
222
 
259
223
  def test_abbrev_not_in_text_produces_no_glossary():
260
224
  abbrevs = {"XYZ": "Some Definition"}
261
- nodes = (_para("Nothing relevant here."),)
262
- result = apply_abbreviations(nodes, abbrevs, page_text="Nothing relevant here.")
263
- # XYZ never mentioned → no glossary
225
+ result = apply_abbreviations((_para("Nothing relevant here."),), abbrevs, page_text="Nothing relevant here.")
264
226
  assert len(result) == 1
265
227
 
266
228
 
267
- def test_glossary_entries_sorted_alphabetically():
229
+ def test_extras_sorted_alphabetically():
268
230
  abbrevs = {"RBAC": "Role-Based Access Control", "IAM": "Identity and Access Management"}
269
- # Both only in heading (unsafe), so both go to glossary
270
- section = Section(
271
- level=1,
272
- anchor="overview",
273
- title=(TextNode("IAM and RBAC Overview"),),
274
- children=(),
275
- )
276
- result = apply_abbreviations(
277
- (section,), abbrevs, page_text="IAM and RBAC Overview"
278
- )
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")
279
233
  glossary = result[-1]
280
- assert isinstance(glossary, Section)
281
- items = glossary.children[0].items # type: ignore[union-attr]
282
- labels = [item.children[0].children[0].text for item in items] # type: ignore[union-attr]
283
- assert labels == sorted(labels)
234
+ assert isinstance(glossary, AbbrevGlossaryBlock)
235
+ extra_abbrs = [abbr for abbr, _ in glossary.extras]
236
+ assert extra_abbrs == sorted(extra_abbrs)