|
| 1 | +"""Footnote ID and subId normalization logic.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from markdown_it.rules_core import StateCore |
| 6 | + |
| 7 | + |
| 8 | +def reorder_footnotes_by_definition(state: StateCore) -> None: |
| 9 | + """Reorder footnotes to match definition order and normalize subIds. |
| 10 | +
|
| 11 | + The mdit-py-plugins footnote plugin assigns IDs and subIds based on the |
| 12 | + order references are encountered during inline parsing. This causes HTML |
| 13 | + to differ when footnote definitions are reordered by the formatter. |
| 14 | +
|
| 15 | + This rule: |
| 16 | + 1. Preserves orphan footnotes (defined but never referenced) |
| 17 | + 2. Reorders the footnote list to match definition order |
| 18 | + 3. Updates all token IDs to match the new ordering |
| 19 | + 4. Reassigns subIds based on output order (body first, then definitions) |
| 20 | +
|
| 21 | + This ensures consistent HTML output regardless of definition position. |
| 22 | + """ |
| 23 | + if "footnotes" not in state.env: |
| 24 | + return |
| 25 | + |
| 26 | + footnote_data = state.env["footnotes"] |
| 27 | + refs = footnote_data.get("refs", {}) |
| 28 | + old_list = footnote_data.get("list", {}) |
| 29 | + |
| 30 | + if not refs: |
| 31 | + return |
| 32 | + |
| 33 | + new_list: dict[int, dict] = {} |
| 34 | + old_to_new_id: dict[int, int] = {} |
| 35 | + |
| 36 | + for new_id, label_key in enumerate(refs.keys()): |
| 37 | + label = label_key[1:] |
| 38 | + old_id = refs[label_key] |
| 39 | + |
| 40 | + if old_id >= 0 and old_id in old_list: |
| 41 | + new_list[new_id] = old_list[old_id].copy() |
| 42 | + else: |
| 43 | + new_list[new_id] = {"label": label, "count": 0} |
| 44 | + |
| 45 | + if old_id >= 0: |
| 46 | + old_to_new_id[old_id] = new_id |
| 47 | + refs[label_key] = new_id |
| 48 | + |
| 49 | + footnote_data["list"] = new_list |
| 50 | + |
| 51 | + _update_token_ids(state.tokens, old_to_new_id) |
| 52 | + _reassign_subids(state.tokens, refs, new_list) |
| 53 | + |
| 54 | + |
| 55 | +def _update_token_ids(tokens: list, old_to_new_id: dict[int, int]) -> None: |
| 56 | + """Recursively update footnote IDs in tokens.""" |
| 57 | + for token in tokens: |
| 58 | + if token.type in ("footnote_ref", "footnote_anchor"): |
| 59 | + if token.meta and "id" in token.meta: |
| 60 | + old_id = token.meta["id"] |
| 61 | + if old_id in old_to_new_id: |
| 62 | + token.meta["id"] = old_to_new_id[old_id] |
| 63 | + if token.children: |
| 64 | + _update_token_ids(token.children, old_to_new_id) |
| 65 | + |
| 66 | + |
| 67 | +def _partition_refs_by_context( |
| 68 | + tokens: list, |
| 69 | +) -> tuple[list, dict[str, list]]: |
| 70 | + """Partition footnote refs into body refs and definition refs.""" |
| 71 | + body_refs: list = [] |
| 72 | + def_refs: dict[str, list] = {} |
| 73 | + current_def_label: str | None = None |
| 74 | + |
| 75 | + for token in tokens: |
| 76 | + if token.type == "footnote_reference_open": |
| 77 | + current_def_label = token.meta.get("label") |
| 78 | + if current_def_label: |
| 79 | + def_refs.setdefault(current_def_label, []) |
| 80 | + elif token.type == "footnote_reference_close": |
| 81 | + current_def_label = None |
| 82 | + elif current_def_label is None: |
| 83 | + _collect_refs(token, body_refs) |
| 84 | + else: |
| 85 | + _collect_refs(token, def_refs.setdefault(current_def_label, [])) |
| 86 | + |
| 87 | + return body_refs, def_refs |
| 88 | + |
| 89 | + |
| 90 | +def _assign_subids_to_refs(ref_tokens: list, counters: dict[int, int]) -> None: |
| 91 | + """Assign sequential subIds to a list of ref tokens.""" |
| 92 | + for ref_token in ref_tokens: |
| 93 | + fn_id = ref_token.meta["id"] |
| 94 | + ref_token.meta["subId"] = counters.get(fn_id, 0) |
| 95 | + counters[fn_id] = counters.get(fn_id, 0) + 1 |
| 96 | + |
| 97 | + |
| 98 | +def _reassign_subids(tokens: list, refs: dict, footnote_list: dict) -> None: |
| 99 | + """Reassign subIds based on output order: body refs first, then definition refs.""" |
| 100 | + body_refs, def_refs = _partition_refs_by_context(tokens) |
| 101 | + subid_counters: dict[int, int] = {} |
| 102 | + |
| 103 | + _assign_subids_to_refs(body_refs, subid_counters) |
| 104 | + |
| 105 | + for label_key in refs.keys(): |
| 106 | + label = label_key[1:] |
| 107 | + if label in def_refs: |
| 108 | + _assign_subids_to_refs(def_refs[label], subid_counters) |
| 109 | + |
| 110 | + for fn_id, count in subid_counters.items(): |
| 111 | + if fn_id in footnote_list: |
| 112 | + footnote_list[fn_id]["count"] = count |
| 113 | + |
| 114 | + |
| 115 | +def _collect_refs(token, ref_list: list) -> None: |
| 116 | + """Collect footnote_ref tokens from a token and its children.""" |
| 117 | + if token.type == "footnote_ref" and token.meta: |
| 118 | + ref_list.append(token) |
| 119 | + if hasattr(token, "children") and token.children: |
| 120 | + for child in token.children: |
| 121 | + _collect_refs(child, ref_list) |
0 commit comments