Merge pull request #2626 from ehuss/footnote-backrefs-style

Add footnote backreferences, and update styling
This commit is contained in:
Eric Huss 2025-03-30 13:51:46 +00:00 committed by GitHub
commit a3c0ecdb45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 929 additions and 81 deletions

View file

@ -200,18 +200,53 @@ sup {
line-height: 0;
}
:not(.footnote-definition) + .footnote-definition {
margin-block-start: 2em;
}
.footnote-definition:not(:has(+ .footnote-definition)) {
margin-block-end: 2em;
}
.footnote-definition {
font-size: 0.9em;
margin: 0.5em 0;
}
.footnote-definition p {
display: inline;
/* The default spacing for a list is a little too large. */
.footnote-definition ul,
.footnote-definition ol {
padding-left: 20px;
}
.footnote-definition > li {
/* Required to position the ::before target */
position: relative;
}
.footnote-definition > li:target {
scroll-margin-top: 50vh;
}
.footnote-reference:target {
scroll-margin-top: 50vh;
}
/* Draws a border around the footnote (including the marker) when it is selected.
TODO: If there are multiple linkbacks, highlight which one you just came
from so you know which one to click.
*/
.footnote-definition > li:target::before {
border: 2px solid var(--footnote-highlight);
border-radius: 6px;
position: absolute;
top: -8px;
right: -8px;
bottom: -8px;
left: -32px;
pointer-events: none;
content: "";
}
/* Pulses the footnote reference so you can quickly see where you left off reading.
This could use some improvement.
*/
@media not (prefers-reduced-motion) {
.footnote-reference:target {
animation: fn-highlight 0.8s;
border-radius: 2px;
}
@keyframes fn-highlight {
from {
background-color: var(--footnote-highlight);
}
}
}
.tooltiptext {

View file

@ -61,6 +61,8 @@
--copy-button-filter: invert(45%) sepia(6%) saturate(621%) hue-rotate(198deg) brightness(99%) contrast(85%);
/* Same as `--sidebar-active` */
--copy-button-filter-hover: invert(68%) sepia(55%) saturate(531%) hue-rotate(341deg) brightness(104%) contrast(101%);
--footnote-highlight: #2668a6;
}
.coal {
@ -110,6 +112,8 @@
--copy-button-filter: invert(26%) sepia(8%) saturate(575%) hue-rotate(169deg) brightness(87%) contrast(82%);
/* Same as `--sidebar-active` */
--copy-button-filter-hover: invert(36%) sepia(70%) saturate(503%) hue-rotate(167deg) brightness(98%) contrast(89%);
--footnote-highlight: #4079ae;
}
.light, html:not(.js) {
@ -159,6 +163,8 @@
--copy-button-filter: invert(45.49%);
/* Same as `--sidebar-active` */
--copy-button-filter-hover: invert(14%) sepia(93%) saturate(4250%) hue-rotate(243deg) brightness(99%) contrast(130%);
--footnote-highlight: #7e7eff;
}
.navy {
@ -208,6 +214,8 @@
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
/* Same as `--sidebar-active` */
--copy-button-filter-hover: invert(46%) sepia(20%) saturate(1537%) hue-rotate(156deg) brightness(85%) contrast(90%);
--footnote-highlight: #4079ae;
}
.rust {
@ -255,6 +263,8 @@
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
/* Same as `--sidebar-active` */
--copy-button-filter-hover: invert(77%) sepia(16%) saturate(1798%) hue-rotate(328deg) brightness(98%) contrast(83%);
--footnote-highlight: #d3a17a;
}
@media (prefers-color-scheme: dark) {

View file

@ -212,18 +212,156 @@ pub fn render_markdown_with_path(
smart_punctuation: bool,
path: Option<&Path>,
) -> String {
let mut s = String::with_capacity(text.len() * 3 / 2);
let p = new_cmark_parser(text, smart_punctuation);
let events = p
let mut body = String::with_capacity(text.len() * 3 / 2);
// Based on
// https://github.com/pulldown-cmark/pulldown-cmark/blob/master/pulldown-cmark/examples/footnote-rewrite.rs
// This handling of footnotes is a two-pass process. This is done to
// support linkbacks, little arrows that allow you to jump back to the
// footnote reference. The first pass collects the footnote definitions.
// The second pass modifies those definitions to include the linkbacks,
// and inserts the definitions back into the `events` list.
// This is a map of name -> (number, count)
// `name` is the name of the footnote.
// `number` is the footnote number displayed in the output.
// `count` is the number of references to this footnote (used for multiple
// linkbacks, and checking for unused footnotes).
let mut footnote_numbers = HashMap::new();
// This is a list of (name, Vec<Event>)
// `name` is the name of the footnote.
// The events list is the list of events needed to build the footnote definition.
let mut footnote_defs = Vec::new();
// The following are used when currently processing a footnote definition.
//
// This is the name of the footnote (escaped).
let mut in_footnote_name = String::new();
// This is the list of events to build the footnote definition.
let mut in_footnote = Vec::new();
let events = new_cmark_parser(text, smart_punctuation)
.map(clean_codeblock_headers)
.map(|event| adjust_links(event, path))
.flat_map(|event| {
let (a, b) = wrap_tables(event);
a.into_iter().chain(b)
})
// Footnote rewriting must go last to ensure inner definition contents
// are processed (since they get pulled out of the initial stream).
.filter_map(|event| {
match event {
Event::Start(Tag::FootnoteDefinition(name)) => {
if !in_footnote.is_empty() {
log::warn!("internal bug: nested footnote not expected in {path:?}");
}
in_footnote_name = special_escape(&name);
None
}
Event::End(TagEnd::FootnoteDefinition) => {
let def_events = std::mem::take(&mut in_footnote);
let name = std::mem::take(&mut in_footnote_name);
footnote_defs.push((name, def_events));
None
}
Event::FootnoteReference(name) => {
let name = special_escape(&name);
let len = footnote_numbers.len() + 1;
let (n, count) = footnote_numbers.entry(name.clone()).or_insert((len, 0));
*count += 1;
let html = Event::Html(
format!(
"<sup class=\"footnote-reference\" id=\"fr-{name}-{count}\">\
<a href=\"#footnote-{name}\">{n}</a>\
</sup>"
)
.into(),
);
if in_footnote_name.is_empty() {
Some(html)
} else {
// While inside a footnote, we need to accumulate.
in_footnote.push(html);
None
}
}
// While inside a footnote, accumulate all events into a local.
_ if !in_footnote_name.is_empty() => {
in_footnote.push(event);
None
}
_ => Some(event),
}
});
html::push_html(&mut s, events);
s
html::push_html(&mut body, events);
if !footnote_defs.is_empty() {
add_footnote_defs(&mut body, path, footnote_defs, &footnote_numbers);
}
body
}
/// Adds all footnote definitions into `body`.
fn add_footnote_defs(
body: &mut String,
path: Option<&Path>,
mut defs: Vec<(String, Vec<Event<'_>>)>,
numbers: &HashMap<String, (usize, u32)>,
) {
// Remove unused.
defs.retain(|(name, _)| {
if !numbers.contains_key(name) {
log::warn!(
"footnote `{name}` in `{}` is defined but not referenced",
path.map_or_else(|| Cow::from("<unknown>"), |p| p.to_string_lossy())
);
false
} else {
true
}
});
defs.sort_by_cached_key(|(name, _)| numbers[name].0);
body.push_str(
"<hr>\n\
<ol class=\"footnote-definition\">",
);
// Insert the backrefs to the definition, and put the definitions in the output.
for (name, mut fn_events) in defs {
let count = numbers[&name].1;
fn_events.insert(
0,
Event::Html(format!("<li id=\"footnote-{name}\">").into()),
);
// Generate the linkbacks.
for usage in 1..=count {
let nth = if usage == 1 {
String::new()
} else {
usage.to_string()
};
let backlink =
Event::Html(format!(" <a href=\"#fr-{name}-{usage}\">↩{nth}</a>").into());
if matches!(fn_events.last(), Some(Event::End(TagEnd::Paragraph))) {
// Put the linkback at the end of the last paragraph instead
// of on a line by itself.
fn_events.insert(fn_events.len() - 1, backlink);
} else {
// Not a clear place to put it in this circumstance, so put it
// at the end.
fn_events.push(backlink);
}
}
fn_events.push(Event::Html("</li>\n".into()));
html::push_html(body, fn_events.into_iter());
}
body.push_str("</ol>");
}
/// Wraps tables in a `.table-wrapper` class to apply overflow-x rules to.
@ -267,13 +405,14 @@ pub fn log_backtrace(e: &Error) {
pub(crate) fn special_escape(mut s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
let needs_escape: &[char] = &['<', '>', '\'', '\\', '&'];
let needs_escape: &[char] = &['<', '>', '\'', '"', '\\', '&'];
while let Some(next) = s.find(needs_escape) {
escaped.push_str(&s[..next]);
match s.as_bytes()[next] {
b'<' => escaped.push_str("&lt;"),
b'>' => escaped.push_str("&gt;"),
b'\'' => escaped.push_str("&#39;"),
b'"' => escaped.push_str("&quot;"),
b'\\' => escaped.push_str("&#92;"),
b'&' => escaped.push_str("&amp;"),
_ => unreachable!(),

View file

@ -15,8 +15,34 @@ Footnote example[^1], or with a word[^word].
[^1]: This is a footnote.
[^word]: A longer footnote.
With multiple lines.
Third line.
With multiple lines. [Link to unicode](unicode.md).
With a reference inside.[^1]
There are multiple references to word[^word].
Footnote without a paragraph[^para]
[^para]:
1. Item one
1. Sub-item
2. Item two
Footnote with multiple paragraphs[^multiple]
[^define-before-use]: This is defined before it is referred to.
<!-- Using <p> tags to work around rustdoc issue, this should move to a separate book.
https://github.com/rust-lang/rust/issues/139064
-->
[^multiple]: <p>One</p><p>Two</p><p>Three</p>
[^unused]: This footnote is defined by not used.
Footnote name with wacky characters[^"wacky"]
[^"wacky"]: Testing footnote id with special characters.
Testing when referring to something earlier.[^define-before-use]
## Strikethrough

View file

@ -545,10 +545,38 @@ fn markdown_options() {
assert_contains_strings(
&path,
&[
r##"<sup class="footnote-reference"><a href="#1">1</a></sup>"##,
r##"<sup class="footnote-reference"><a href="#word">2</a></sup>"##,
r##"<div class="footnote-definition" id="1"><sup class="footnote-definition-label">1</sup>"##,
r##"<div class="footnote-definition" id="word"><sup class="footnote-definition-label">2</sup>"##,
r##"<sup class="footnote-reference" id="fr-1-1"><a href="#footnote-1">1</a></sup>"##,
r##"<sup class="footnote-reference" id="fr-word-1"><a href="#footnote-word">2</a></sup>"##,
r##"<sup class="footnote-reference" id="fr-word-2"><a href="#footnote-word">2</a></sup>"##,
r##"<hr>
<ol class="footnote-definition"><li id="footnote-1">
<p>This is a footnote. <a href="#fr-1-1"></a> <a href="#fr-1-2">2</a></p>
</li>
<li id="footnote-word">
<p>A longer footnote.
With multiple lines. <a href="unicode.html">Link to unicode</a>.
With a reference inside.<sup class="footnote-reference" id="fr-1-2"><a href="#footnote-1">1</a></sup> <a href="#fr-word-1"></a> <a href="#fr-word-2">2</a></p>
</li>
<li id="footnote-para">
<ol>
<li>Item one
<ol>
<li>Sub-item</li>
</ol>
</li>
<li>Item two</li>
</ol>
<a href="#fr-para-1"></a></li>
<li id="footnote-multiple"><p>One</p><p>Two</p><p>Three</p>
<a href="#fr-multiple-1"></a></li>
<li id="footnote-&quot;wacky&quot;">
<p>Testing footnote id with special characters. <a href="#fr-&quot;wacky&quot;-1"></a></p>
</li>
<li id="footnote-define-before-use">
<p>This is defined before it is referred to. <a href="#fr-define-before-use-1"></a></p>
</li>
</ol>
"##,
],
);
assert_contains_strings(&path, &["<del>strikethrough example</del>"]);

File diff suppressed because it is too large Load diff