Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions mdformat_footnote/_position.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 10 additions & 10 deletions mdformat_footnote/_reorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@


@dataclass
class _FootnoteCategories:
class FootnoteCategories:
"""Categorized footnotes for reordering."""

body_referenced: list[tuple[int, str, str]]
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion mdformat_footnote/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)

Expand Down
62 changes: 62 additions & 0 deletions tests/fixtures/keep_position.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
.
12 changes: 7 additions & 5 deletions tests/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading