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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change Log

## Unreleased

- 🐛 FIX: Reserve inline footnote IDs before parsing their contents, avoiding
overwritten notes, duplicate anchors and `KeyError` when those contents refer
to another footnote.

## 0.7.0 - 2026-07-19

- ✨ NEW: Add section reference plugin (`section_ref`) (#144)
Expand Down
5 changes: 5 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ html_string = md.render("some *Markdown*")

## Footnotes

Inline footnotes keep separate identifiers when their contents refer to other
footnotes. For example, `^[See also [^source]]` and a later `[^source]` reference
both link to the same named footnote definition without overwriting the inline
note.

```{eval-rst}
.. autofunction:: mdit_py_plugins.footnote.footnote_plugin
```
Expand Down
4 changes: 2 additions & 2 deletions mdit_py_plugins/footnote/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,15 @@ def footnote_inline(state: StateInline, silent: bool) -> bool:
footnoteId = len(refs)

tokens: list[Token] = []
# Reserve the ID before parsing content that may allocate more footnotes.
refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens}
state.md.inline.parse(
state.src[labelStart:labelEnd], state.md, state.env, tokens
)

token = state.push("footnote_ref", "", 0)
token.meta = {"id": footnoteId}

refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens}

state.pos = labelEnd + 1
state.posMax = maximum
return True
Expand Down
61 changes: 61 additions & 0 deletions tests/test_footnote.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
import re
from textwrap import dedent

from markdown_it import MarkdownIt
Expand Down Expand Up @@ -438,6 +439,66 @@ def test_plugin_render():
)


@pytest.mark.parametrize("move_to_end", [False, True])
@pytest.mark.parametrize(
"source,contents,counts",
[
(
"^[outer [^named]]\n\n[^named]: named body",
["outer [^named]", "named"],
[None, 1],
),
(
"^[outer [^named]] again[^named]\n\n[^named]: named body",
["outer [^named]", "named"],
[None, 2],
),
("^[outer ^[inner]]", ["outer ^[inner]", "inner"], [None, None]),
(
"^[outer ^[middle ^[inner]]]",
["outer ^[middle ^[inner]]", "middle ^[inner]", "inner"],
[None, None, None],
),
(
"^[first] ^[outer [^named]] again[^named]\n\n[^named]: named body",
["first", "outer [^named]", "named"],
[None, None, 2],
),
(
"before[^named] ^[outer[^named]]\n\n[^named]: named body",
["named", "outer[^named]"],
[2, None],
),
(
"^[first] again[^named]\n\n[^named]: named body",
["first", "named"],
[None, 1],
),
],
)
def test_inline_footnote_ids(
source: str, contents: list[str], counts: list[int | None], move_to_end: bool
) -> None:
md = MarkdownIt().use(footnote_plugin, move_to_end=move_to_end)
env = {}
tokens = md.parse(source, env)
notes = env["footnotes"]["list"]
assert list(notes) == list(range(len(contents)))
assert [
note.get("content", note.get("label")) for note in notes.values()
] == contents
assert [note.get("count") for note in notes.values()] == counts

if move_to_end:
rendered = md.renderer.render(tokens, md.options, env)
identifiers = re.findall(r'\bid="([^"]+)"', rendered)
targets = re.findall(r'\bhref="#([^"]+)"', rendered)
assert len(identifiers) == len(set(identifiers))
assert set(targets) <= set(identifiers)
if "named" in contents:
assert "named body" in rendered


@pytest.mark.parametrize("line,title,input,expected", read_fixture_file(FIXTURE_PATH))
def test_all(line, title, input, expected):
md = MarkdownIt().use(
Expand Down