diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b78b3..7b8519c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/index.md b/docs/index.md index 123ab6a..7d2d29f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 ``` diff --git a/mdit_py_plugins/footnote/index.py b/mdit_py_plugins/footnote/index.py index 67c0b67..ffb6703 100644 --- a/mdit_py_plugins/footnote/index.py +++ b/mdit_py_plugins/footnote/index.py @@ -227,6 +227,8 @@ 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 ) @@ -234,8 +236,6 @@ def footnote_inline(state: StateInline, silent: bool) -> bool: 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 diff --git a/tests/test_footnote.py b/tests/test_footnote.py index 2d50ab4..14c2071 100644 --- a/tests/test_footnote.py +++ b/tests/test_footnote.py @@ -1,4 +1,5 @@ from pathlib import Path +import re from textwrap import dedent from markdown_it import MarkdownIt @@ -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(