rustdoc: Allow multiple references to a single footnote

Multiple references to a single footnote is a part of GitHub Flavored
Markdown syntax (although not explicitly documented as well as regular
footnotes, it is implemented in GitHub's fork of CommonMark) and not
prohibited by rustdoc.

cf. <587a12bb54/test/extensions.txt (L762-L780)>

However, using it makes multiple "sup" elements with the same "id"
attribute, which is invalid per the HTML specification.

Still, not only this is a valid GitHub Flavored Markdown syntax, this is
helpful on certain cases and actually tested (accidentally) in
tests/rustdoc/footnote-reference-in-footnote-def.rs.

This commit keeps track of the number of references per footnote and gives
unique ID to each reference.  It also emits *all* back links from a footnote
to its references as "↩" (return symbol) plus a numeric list in superscript.

As a known limitation, it assumes that all references to a footnote are
rendered (this is not always true if a dangling footnote has one or more
references but considered a reasonable compromise).

Also note that, this commit is designed so that no HTML changes will occur
unless multiple references to a single footnote is actually used.
This commit is contained in:
Tsukasa OI
2025-08-14 04:39:31 +00:00
parent 3672a55b7c
commit 74aca53f55
3 changed files with 48 additions and 10 deletions

View File

@@ -23,6 +23,8 @@ struct FootnoteDef<'a> {
content: Vec<Event<'a>>,
/// The number that appears in the footnote reference and list.
id: usize,
/// The number of footnote references.
num_refs: usize,
}
impl<'a, I: Iterator<Item = SpannedEvent<'a>>> Footnotes<'a, I> {
@@ -33,21 +35,25 @@ impl<'a, I: Iterator<Item = SpannedEvent<'a>>> Footnotes<'a, I> {
Footnotes { inner: iter, footnotes: FxIndexMap::default(), existing_footnotes, start_id }
}
fn get_entry(&mut self, key: &str) -> (&mut Vec<Event<'a>>, usize) {
fn get_entry(&mut self, key: &str) -> (&mut Vec<Event<'a>>, usize, &mut usize) {
let new_id = self.footnotes.len() + 1 + self.start_id;
let key = key.to_owned();
let FootnoteDef { content, id } =
self.footnotes.entry(key).or_insert(FootnoteDef { content: Vec::new(), id: new_id });
let FootnoteDef { content, id, num_refs } = self
.footnotes
.entry(key)
.or_insert(FootnoteDef { content: Vec::new(), id: new_id, num_refs: 0 });
// Don't allow changing the ID of existing entries, but allow changing the contents.
(content, *id)
(content, *id, num_refs)
}
fn handle_footnote_reference(&mut self, reference: &CowStr<'a>) -> Event<'a> {
// When we see a reference (to a footnote we may not know) the definition of,
// reserve a number for it, and emit a link to that number.
let (_, id) = self.get_entry(reference);
let (_, id, num_refs) = self.get_entry(reference);
*num_refs += 1;
let fnref_suffix = if *num_refs <= 1 { "".to_owned() } else { format!("-{num_refs}") };
let reference = format!(
"<sup id=\"fnref{0}\"><a href=\"#fn{0}\">{1}</a></sup>",
"<sup id=\"fnref{0}{fnref_suffix}\"><a href=\"#fn{0}\">{1}</a></sup>",
id,
// Although the ID count is for the whole page, the footnote reference
// are local to the item so we make this ID "local" when displayed.
@@ -85,7 +91,7 @@ impl<'a, I: Iterator<Item = SpannedEvent<'a>>> Iterator for Footnotes<'a, I> {
// When we see a footnote definition, collect the associated content, and store
// that for rendering later.
let content = self.collect_footnote_def();
let (entry_content, _) = self.get_entry(&def);
let (entry_content, _, _) = self.get_entry(&def);
*entry_content = content;
}
Some(e) => return Some(e),
@@ -113,7 +119,7 @@ fn render_footnotes_defs(mut footnotes: Vec<FootnoteDef<'_>>) -> String {
// browser generated for <li> are right.
footnotes.sort_by_key(|x| x.id);
for FootnoteDef { mut content, id } in footnotes {
for FootnoteDef { mut content, id, num_refs } in footnotes {
write!(ret, "<li id=\"fn{id}\">").unwrap();
let mut is_paragraph = false;
if let Some(&Event::End(TagEnd::Paragraph)) = content.last() {
@@ -121,7 +127,16 @@ fn render_footnotes_defs(mut footnotes: Vec<FootnoteDef<'_>>) -> String {
is_paragraph = true;
}
html::push_html(&mut ret, content.into_iter());
write!(ret, "&nbsp;<a href=\"#fnref{id}\">↩</a>").unwrap();
if num_refs <= 1 {
write!(ret, "&nbsp;<a href=\"#fnref{id}\">↩</a>").unwrap();
} else {
// There are multiple references to single footnote. Make the first
// back link a single "a" element to make touch region larger.
write!(ret, "&nbsp;<a href=\"#fnref{id}\">↩&nbsp;<sup>1</sup></a>").unwrap();
for refid in 2..=num_refs {
write!(ret, "&nbsp;<sup><a href=\"#fnref{id}-{refid}\">{refid}</a></sup>").unwrap();
}
}
if is_paragraph {
ret.push_str("</p>");
}

View File

@@ -0,0 +1,23 @@
// This test ensures that multiple references to a single footnote and
// corresponding back links work as expected.
#![crate_name = "foo"]
//@ has 'foo/index.html'
//@ has - '//*[@class="docblock"]/p/sup[@id="fnref1"]/a[@href="#fn1"]' '1'
//@ has - '//*[@class="docblock"]/p/sup[@id="fnref2"]/a[@href="#fn2"]' '2'
//@ has - '//*[@class="docblock"]/p/sup[@id="fnref2-2"]/a[@href="#fn2"]' '2'
//@ has - '//li[@id="fn1"]/p' 'meow'
//@ has - '//li[@id="fn1"]/p/a[@href="#fnref1"]' '↩'
//@ has - '//li[@id="fn2"]/p' 'uwu'
//@ has - '//li[@id="fn2"]/p/a[@href="#fnref2"]/sup' '1'
//@ has - '//li[@id="fn2"]/p/sup/a[@href="#fnref2-2"]' '2'
//! # Footnote, references and back links
//!
//! Single: [^a].
//!
//! Double: [^b] [^b].
//!
//! [^a]: meow
//! [^b]: uwu

View File

@@ -9,7 +9,7 @@
//@ has - '//li[@id="fn1"]/p/sup[@id="fnref2"]/a[@href="#fn2"]' '2'
//@ has - '//li[@id="fn1"]//a[@href="#fn2"]' '2'
//@ has - '//li[@id="fn2"]/p' 'uwu'
//@ has - '//li[@id="fn2"]/p/sup[@id="fnref1"]/a[@href="#fn1"]' '1'
//@ has - '//li[@id="fn2"]/p/sup[@id="fnref1-2"]/a[@href="#fn1"]' '1'
//@ has - '//li[@id="fn2"]//a[@href="#fn1"]' '1'
//! # footnote-hell