From 96540f02454eab4181f11fb9020256e399205c22 Mon Sep 17 00:00:00 2001 From: Kyle King Date: Mon, 31 Aug 2026 19:51:21 -0600 Subject: [PATCH 1/2] refactor: unprefix footnote-categorization helpers for cross-module reuse _categorize_footnotes, build_dependency_graph, and collect_refs_in_fences are about to be shared with the position-keeping orphan handling, so they no longer belong to a single file. --- mdformat_footnote/_reorder.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/mdformat_footnote/_reorder.py b/mdformat_footnote/_reorder.py index 71c1bbf..0fbb3a2 100644 --- a/mdformat_footnote/_reorder.py +++ b/mdformat_footnote/_reorder.py @@ -11,7 +11,7 @@ @dataclass -class _FootnoteCategories: +class FootnoteCategories: """Categorized footnotes for reordering.""" body_referenced: list[tuple[int, str, str]] @@ -65,7 +65,7 @@ def add_footnote( self.new_id += 1 -def _collect_refs_in_fences(tokens: list) -> list[str]: +def collect_refs_in_fences(tokens: list) -> list[str]: """Collect footnote labels referenced in fence tokens, preserving order.""" refs: list[str] = [] seen: set[str] = set() @@ -80,7 +80,7 @@ def _collect_refs_in_fences(tokens: list) -> list[str]: return refs -def _build_dependency_graph(tokens: list) -> dict[str, set[str]]: +def build_dependency_graph(tokens: list) -> dict[str, set[str]]: """Build a graph of which footnotes reference which others.""" graph: dict[str, set[str]] = {} current_def_label: str | None = None @@ -107,11 +107,11 @@ def _collect_nested_refs(token, ref_set: set[str]) -> None: _collect_nested_refs(child, ref_set) -def _categorize_footnotes( +def categorize_footnotes( refs: dict, footnote_deps: dict[str, set[str]], refs_in_fences: list[str], -) -> _FootnoteCategories: +) -> FootnoteCategories: """Categorize footnotes.""" referenced_by_footnotes: set[str] = set() for refs_set in footnote_deps.values(): @@ -143,7 +143,7 @@ def _categorize_footnotes( body_referenced.sort(key=lambda x: x[0]) fence_only = [label for label in refs_in_fences if label in fence_only_set] - return _FootnoteCategories(body_referenced, nested_only, fence_only, true_orphans) + return FootnoteCategories(body_referenced, nested_only, fence_only, true_orphans) def _process_nested_for_parent( @@ -159,7 +159,7 @@ def _process_nested_for_parent( def _build_reordered_list( - categories: _FootnoteCategories, + categories: FootnoteCategories, footnote_deps: dict[str, set[str]], old_list: dict, refs: dict, @@ -268,9 +268,9 @@ def reorder_footnotes_by_definition( return refs, old_list = data - footnote_deps = _build_dependency_graph(state.tokens) - refs_in_fences = _collect_refs_in_fences(state.tokens) - categories = _categorize_footnotes(refs, footnote_deps, refs_in_fences) + footnote_deps = build_dependency_graph(state.tokens) + refs_in_fences = collect_refs_in_fences(state.tokens) + categories = categorize_footnotes(refs, footnote_deps, refs_in_fences) if not keep_orphans: for orphan_key in categories.true_orphans: From 27474d25a7082f21082f27a65fcef2b332b4f4a6 Mon Sep 17 00:00:00 2001 From: Kyle King Date: Mon, 31 Aug 2026 19:52:16 -0600 Subject: [PATCH 2/2] feat: strip orphan footnotes when keeping position Reuses the existing fence/nested-reference categorization so a footnote referenced only inside a code fence or only from another footnote isn't wrongly treated as an orphan. Honors --keep-footnote-orphans. --- mdformat_footnote/_position.py | 51 +++++++++++++++++++++++++++ mdformat_footnote/plugin.py | 5 ++- tests/fixtures/keep_position.md | 62 +++++++++++++++++++++++++++++++++ tests/test_fixtures.py | 12 ++++--- 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 mdformat_footnote/_position.py diff --git a/mdformat_footnote/_position.py b/mdformat_footnote/_position.py new file mode 100644 index 0000000..328afaf --- /dev/null +++ b/mdformat_footnote/_position.py @@ -0,0 +1,51 @@ +"""Orphan handling for footnotes kept at their source position.""" + +from __future__ import annotations + +from markdown_it.rules_core import StateCore + +from ._reorder import ( + build_dependency_graph, + categorize_footnotes, + collect_refs_in_fences, +) + + +def _drop_footnote_spans(tokens: list, labels: set[str]) -> list: + """Remove footnote_reference_open/close spans for the given labels.""" + kept = [] + skip = False + for token in tokens: + if token.type == "footnote_reference_open": + skip = token.meta.get("label") in labels + if skip: + continue + elif token.type == "footnote_reference_close" and skip: + skip = False + continue + if not skip: + kept.append(token) + return kept + + +def strip_orphan_footnotes(state: StateCore, keep_orphans: bool = False) -> None: + """Remove footnote definitions that are never referenced, in place.""" + if keep_orphans: + return + + footnote_data = state.env.get("footnotes", {}) + refs = footnote_data.get("refs", {}) + if not refs: + return + + footnote_deps = build_dependency_graph(state.tokens) + refs_in_fences = collect_refs_in_fences(state.tokens) + categories = categorize_footnotes(refs, footnote_deps, refs_in_fences) + if not categories.true_orphans: + return + + for label_key in categories.true_orphans: + del refs[label_key] + + orphan_labels = {label_key[1:] for label_key in categories.true_orphans} + state.tokens = _drop_footnote_spans(state.tokens, orphan_labels) diff --git a/mdformat_footnote/plugin.py b/mdformat_footnote/plugin.py index 03da324..a663d4d 100644 --- a/mdformat_footnote/plugin.py +++ b/mdformat_footnote/plugin.py @@ -11,6 +11,7 @@ from mdit_py_plugins.footnote import footnote_plugin from ._helpers import ContextOptions, get_conf +from ._position import strip_orphan_footnotes from ._reorder import reorder_footnotes_by_definition @@ -58,11 +59,13 @@ def update_mdit(mdit: MarkdownIt) -> None: # Disable inline footnotes for now, since we don't have rendering # support for them yet. mdit.disable("footnote_inline") + keep_orphans = _keep_orphans(mdit.options) if keep_position: + strip_fn = partial(strip_orphan_footnotes, keep_orphans=keep_orphans) + mdit.core.ruler.after("inline", "strip_orphan_footnotes", strip_fn) return # Reorder footnotes by reference order, fix IDs, and handle orphans. # Must run before footnote_tail, which only exists when move_to_end is set. - keep_orphans = _keep_orphans(mdit.options) reorder_fn = partial(reorder_footnotes_by_definition, keep_orphans=keep_orphans) mdit.core.ruler.before("footnote_tail", "reorder_footnotes", reorder_fn) diff --git a/tests/fixtures/keep_position.md b/tests/fixtures/keep_position.md index beaf673..5d709cf 100644 --- a/tests/fixtures/keep_position.md +++ b/tests/fixtures/keep_position.md @@ -57,3 +57,65 @@ Body text.[^a] [^b]: Second. . + +Orphan footnote removed while keeping position +. +Body text.[^used] + +[^used]: This is used. + +[^orphan]: This is never referenced. +. +Body text.[^used] + +[^used]: This is used. +. + +Orphan footnote kept when keep orphans is also set +. +Body text.[^used] + +[^used]: This is used. + +[^orphan]: This is never referenced. +. +Body text.[^used] + +[^used]: This is used. + +[^orphan]: This is never referenced. +. + +Fence-referenced footnote not treated as orphan while keeping position +. +Body text. + +```text +See [^fenced] in the fence. +``` + +[^fenced]: Referenced only inside a code fence. +. +Body text. + +```text +See [^fenced] in the fence. +``` + +[^fenced]: Referenced only inside a code fence. +. + +Nested-only footnote not treated as orphan while keeping position +. +Body text.[^a] + +[^a]: References a nested-only footnote.[^nested] + +[^nested]: Never referenced from the body directly. +. +Body text.[^a] + +[^a]: References a nested-only footnote.[^nested] + +[^nested]: Never referenced from the body directly. +. diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index fdad346..18455b7 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -14,11 +14,13 @@ def _get_options(filename: str, title: str) -> dict: if match := re.search(r"wrap at (\d+)", title): return {"wrap": int(match.group(1))} return {"wrap": 40} - if "keep orphans" in title.lower(): - return {"keep_orphans": True} - if "keep position" in title.lower() or filename == "keep_position.md": - return {"keep_position": True} - return {} + lowered = title.lower() + options: dict = {} + if "keep orphans" in lowered: + options["keep_orphans"] = True + if "keep position" in lowered or filename == "keep_position.md": + options["keep_position"] = True + return options # Load all fixture files