Skip to content

Commit a3c0ecd

Browse files
authored
Merge pull request #2626 from ehuss/footnote-backrefs-style
Add footnote backreferences, and update styling
2 parents a56cffe + b20b175 commit a3c0ecd

File tree

6 files changed

+935
-87
lines changed

6 files changed

+935
-87
lines changed

src/theme/css/general.css

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,18 +200,53 @@ sup {
200200
line-height: 0;
201201
}
202202

203-
:not(.footnote-definition) + .footnote-definition {
204-
margin-block-start: 2em;
205-
}
206-
.footnote-definition:not(:has(+ .footnote-definition)) {
207-
margin-block-end: 2em;
208-
}
209203
.footnote-definition {
210204
font-size: 0.9em;
211-
margin: 0.5em 0;
212205
}
213-
.footnote-definition p {
214-
display: inline;
206+
/* The default spacing for a list is a little too large. */
207+
.footnote-definition ul,
208+
.footnote-definition ol {
209+
padding-left: 20px;
210+
}
211+
.footnote-definition > li {
212+
/* Required to position the ::before target */
213+
position: relative;
214+
}
215+
.footnote-definition > li:target {
216+
scroll-margin-top: 50vh;
217+
}
218+
.footnote-reference:target {
219+
scroll-margin-top: 50vh;
220+
}
221+
/* Draws a border around the footnote (including the marker) when it is selected.
222+
TODO: If there are multiple linkbacks, highlight which one you just came
223+
from so you know which one to click.
224+
*/
225+
.footnote-definition > li:target::before {
226+
border: 2px solid var(--footnote-highlight);
227+
border-radius: 6px;
228+
position: absolute;
229+
top: -8px;
230+
right: -8px;
231+
bottom: -8px;
232+
left: -32px;
233+
pointer-events: none;
234+
content: "";
235+
}
236+
/* Pulses the footnote reference so you can quickly see where you left off reading.
237+
This could use some improvement.
238+
*/
239+
@media not (prefers-reduced-motion) {
240+
.footnote-reference:target {
241+
animation: fn-highlight 0.8s;
242+
border-radius: 2px;
243+
}
244+
245+
@keyframes fn-highlight {
246+
from {
247+
background-color: var(--footnote-highlight);
248+
}
249+
}
215250
}
216251

217252
.tooltiptext {

src/theme/css/variables.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
--copy-button-filter: invert(45%) sepia(6%) saturate(621%) hue-rotate(198deg) brightness(99%) contrast(85%);
6262
/* Same as `--sidebar-active` */
6363
--copy-button-filter-hover: invert(68%) sepia(55%) saturate(531%) hue-rotate(341deg) brightness(104%) contrast(101%);
64+
65+
--footnote-highlight: #2668a6;
6466
}
6567

6668
.coal {
@@ -110,6 +112,8 @@
110112
--copy-button-filter: invert(26%) sepia(8%) saturate(575%) hue-rotate(169deg) brightness(87%) contrast(82%);
111113
/* Same as `--sidebar-active` */
112114
--copy-button-filter-hover: invert(36%) sepia(70%) saturate(503%) hue-rotate(167deg) brightness(98%) contrast(89%);
115+
116+
--footnote-highlight: #4079ae;
113117
}
114118

115119
.light, html:not(.js) {
@@ -159,6 +163,8 @@
159163
--copy-button-filter: invert(45.49%);
160164
/* Same as `--sidebar-active` */
161165
--copy-button-filter-hover: invert(14%) sepia(93%) saturate(4250%) hue-rotate(243deg) brightness(99%) contrast(130%);
166+
167+
--footnote-highlight: #7e7eff;
162168
}
163169

164170
.navy {
@@ -208,6 +214,8 @@
208214
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
209215
/* Same as `--sidebar-active` */
210216
--copy-button-filter-hover: invert(46%) sepia(20%) saturate(1537%) hue-rotate(156deg) brightness(85%) contrast(90%);
217+
218+
--footnote-highlight: #4079ae;
211219
}
212220

213221
.rust {
@@ -255,6 +263,8 @@
255263
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
256264
/* Same as `--sidebar-active` */
257265
--copy-button-filter-hover: invert(77%) sepia(16%) saturate(1798%) hue-rotate(328deg) brightness(98%) contrast(83%);
266+
267+
--footnote-highlight: #d3a17a;
258268
}
259269

260270
@media (prefers-color-scheme: dark) {

src/utils/mod.rs

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,18 +212,156 @@ pub fn render_markdown_with_path(
212212
smart_punctuation: bool,
213213
path: Option<&Path>,
214214
) -> String {
215-
let mut s = String::with_capacity(text.len() * 3 / 2);
216-
let p = new_cmark_parser(text, smart_punctuation);
217-
let events = p
215+
let mut body = String::with_capacity(text.len() * 3 / 2);
216+
217+
// Based on
218+
// https://github.com/pulldown-cmark/pulldown-cmark/blob/master/pulldown-cmark/examples/footnote-rewrite.rs
219+
220+
// This handling of footnotes is a two-pass process. This is done to
221+
// support linkbacks, little arrows that allow you to jump back to the
222+
// footnote reference. The first pass collects the footnote definitions.
223+
// The second pass modifies those definitions to include the linkbacks,
224+
// and inserts the definitions back into the `events` list.
225+
226+
// This is a map of name -> (number, count)
227+
// `name` is the name of the footnote.
228+
// `number` is the footnote number displayed in the output.
229+
// `count` is the number of references to this footnote (used for multiple
230+
// linkbacks, and checking for unused footnotes).
231+
let mut footnote_numbers = HashMap::new();
232+
// This is a list of (name, Vec<Event>)
233+
// `name` is the name of the footnote.
234+
// The events list is the list of events needed to build the footnote definition.
235+
let mut footnote_defs = Vec::new();
236+
237+
// The following are used when currently processing a footnote definition.
238+
//
239+
// This is the name of the footnote (escaped).
240+
let mut in_footnote_name = String::new();
241+
// This is the list of events to build the footnote definition.
242+
let mut in_footnote = Vec::new();
243+
244+
let events = new_cmark_parser(text, smart_punctuation)
218245
.map(clean_codeblock_headers)
219246
.map(|event| adjust_links(event, path))
220247
.flat_map(|event| {
221248
let (a, b) = wrap_tables(event);
222249
a.into_iter().chain(b)
250+
})
251+
// Footnote rewriting must go last to ensure inner definition contents
252+
// are processed (since they get pulled out of the initial stream).
253+
.filter_map(|event| {
254+
match event {
255+
Event::Start(Tag::FootnoteDefinition(name)) => {
256+
if !in_footnote.is_empty() {
257+
log::warn!("internal bug: nested footnote not expected in {path:?}");
258+
}
259+
in_footnote_name = special_escape(&name);
260+
None
261+
}
262+
Event::End(TagEnd::FootnoteDefinition) => {
263+
let def_events = std::mem::take(&mut in_footnote);
264+
let name = std::mem::take(&mut in_footnote_name);
265+
footnote_defs.push((name, def_events));
266+
None
267+
}
268+
Event::FootnoteReference(name) => {
269+
let name = special_escape(&name);
270+
let len = footnote_numbers.len() + 1;
271+
let (n, count) = footnote_numbers.entry(name.clone()).or_insert((len, 0));
272+
*count += 1;
273+
let html = Event::Html(
274+
format!(
275+
"<sup class=\"footnote-reference\" id=\"fr-{name}-{count}\">\
276+
<a href=\"#footnote-{name}\">{n}</a>\
277+
</sup>"
278+
)
279+
.into(),
280+
);
281+
if in_footnote_name.is_empty() {
282+
Some(html)
283+
} else {
284+
// While inside a footnote, we need to accumulate.
285+
in_footnote.push(html);
286+
None
287+
}
288+
}
289+
// While inside a footnote, accumulate all events into a local.
290+
_ if !in_footnote_name.is_empty() => {
291+
in_footnote.push(event);
292+
None
293+
}
294+
_ => Some(event),
295+
}
223296
});
224297

225-
html::push_html(&mut s, events);
226-
s
298+
html::push_html(&mut body, events);
299+
300+
if !footnote_defs.is_empty() {
301+
add_footnote_defs(&mut body, path, footnote_defs, &footnote_numbers);
302+
}
303+
304+
body
305+
}
306+
307+
/// Adds all footnote definitions into `body`.
308+
fn add_footnote_defs(
309+
body: &mut String,
310+
path: Option<&Path>,
311+
mut defs: Vec<(String, Vec<Event<'_>>)>,
312+
numbers: &HashMap<String, (usize, u32)>,
313+
) {
314+
// Remove unused.
315+
defs.retain(|(name, _)| {
316+
if !numbers.contains_key(name) {
317+
log::warn!(
318+
"footnote `{name}` in `{}` is defined but not referenced",
319+
path.map_or_else(|| Cow::from("<unknown>"), |p| p.to_string_lossy())
320+
);
321+
false
322+
} else {
323+
true
324+
}
325+
});
326+
327+
defs.sort_by_cached_key(|(name, _)| numbers[name].0);
328+
329+
body.push_str(
330+
"<hr>\n\
331+
<ol class=\"footnote-definition\">",
332+
);
333+
334+
// Insert the backrefs to the definition, and put the definitions in the output.
335+
for (name, mut fn_events) in defs {
336+
let count = numbers[&name].1;
337+
fn_events.insert(
338+
0,
339+
Event::Html(format!("<li id=\"footnote-{name}\">").into()),
340+
);
341+
// Generate the linkbacks.
342+
for usage in 1..=count {
343+
let nth = if usage == 1 {
344+
String::new()
345+
} else {
346+
usage.to_string()
347+
};
348+
let backlink =
349+
Event::Html(format!(" <a href=\"#fr-{name}-{usage}\">↩{nth}</a>").into());
350+
if matches!(fn_events.last(), Some(Event::End(TagEnd::Paragraph))) {
351+
// Put the linkback at the end of the last paragraph instead
352+
// of on a line by itself.
353+
fn_events.insert(fn_events.len() - 1, backlink);
354+
} else {
355+
// Not a clear place to put it in this circumstance, so put it
356+
// at the end.
357+
fn_events.push(backlink);
358+
}
359+
}
360+
fn_events.push(Event::Html("</li>\n".into()));
361+
html::push_html(body, fn_events.into_iter());
362+
}
363+
364+
body.push_str("</ol>");
227365
}
228366

229367
/// Wraps tables in a `.table-wrapper` class to apply overflow-x rules to.
@@ -267,13 +405,14 @@ pub fn log_backtrace(e: &Error) {
267405

268406
pub(crate) fn special_escape(mut s: &str) -> String {
269407
let mut escaped = String::with_capacity(s.len());
270-
let needs_escape: &[char] = &['<', '>', '\'', '\\', '&'];
408+
let needs_escape: &[char] = &['<', '>', '\'', '"', '\\', '&'];
271409
while let Some(next) = s.find(needs_escape) {
272410
escaped.push_str(&s[..next]);
273411
match s.as_bytes()[next] {
274412
b'<' => escaped.push_str("&lt;"),
275413
b'>' => escaped.push_str("&gt;"),
276414
b'\'' => escaped.push_str("&#39;"),
415+
b'"' => escaped.push_str("&quot;"),
277416
b'\\' => escaped.push_str("&#92;"),
278417
b'&' => escaped.push_str("&amp;"),
279418
_ => unreachable!(),

tests/dummy_book/src/first/markdown.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,34 @@ Footnote example[^1], or with a word[^word].
1515
[^1]: This is a footnote.
1616

1717
[^word]: A longer footnote.
18-
With multiple lines.
19-
Third line.
18+
With multiple lines. [Link to unicode](unicode.md).
19+
With a reference inside.[^1]
20+
21+
There are multiple references to word[^word].
22+
23+
Footnote without a paragraph[^para]
24+
25+
[^para]:
26+
1. Item one
27+
1. Sub-item
28+
2. Item two
29+
30+
Footnote with multiple paragraphs[^multiple]
31+
32+
[^define-before-use]: This is defined before it is referred to.
33+
34+
<!-- Using <p> tags to work around rustdoc issue, this should move to a separate book.
35+
https://github.com/rust-lang/rust/issues/139064
36+
-->
37+
[^multiple]: <p>One</p><p>Two</p><p>Three</p>
38+
39+
[^unused]: This footnote is defined by not used.
40+
41+
Footnote name with wacky characters[^"wacky"]
42+
43+
[^"wacky"]: Testing footnote id with special characters.
44+
45+
Testing when referring to something earlier.[^define-before-use]
2046

2147
## Strikethrough
2248

tests/rendered_output.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,10 +545,38 @@ fn markdown_options() {
545545
assert_contains_strings(
546546
&path,
547547
&[
548-
r##"<sup class="footnote-reference"><a href="#1">1</a></sup>"##,
549-
r##"<sup class="footnote-reference"><a href="#word">2</a></sup>"##,
550-
r##"<div class="footnote-definition" id="1"><sup class="footnote-definition-label">1</sup>"##,
551-
r##"<div class="footnote-definition" id="word"><sup class="footnote-definition-label">2</sup>"##,
548+
r##"<sup class="footnote-reference" id="fr-1-1"><a href="#footnote-1">1</a></sup>"##,
549+
r##"<sup class="footnote-reference" id="fr-word-1"><a href="#footnote-word">2</a></sup>"##,
550+
r##"<sup class="footnote-reference" id="fr-word-2"><a href="#footnote-word">2</a></sup>"##,
551+
r##"<hr>
552+
<ol class="footnote-definition"><li id="footnote-1">
553+
<p>This is a footnote. <a href="#fr-1-1">↩</a> <a href="#fr-1-2">↩2</a></p>
554+
</li>
555+
<li id="footnote-word">
556+
<p>A longer footnote.
557+
With multiple lines. <a href="unicode.html">Link to unicode</a>.
558+
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>
559+
</li>
560+
<li id="footnote-para">
561+
<ol>
562+
<li>Item one
563+
<ol>
564+
<li>Sub-item</li>
565+
</ol>
566+
</li>
567+
<li>Item two</li>
568+
</ol>
569+
<a href="#fr-para-1">↩</a></li>
570+
<li id="footnote-multiple"><p>One</p><p>Two</p><p>Three</p>
571+
<a href="#fr-multiple-1">↩</a></li>
572+
<li id="footnote-&quot;wacky&quot;">
573+
<p>Testing footnote id with special characters. <a href="#fr-&quot;wacky&quot;-1">↩</a></p>
574+
</li>
575+
<li id="footnote-define-before-use">
576+
<p>This is defined before it is referred to. <a href="#fr-define-before-use-1">↩</a></p>
577+
</li>
578+
</ol>
579+
"##,
552580
],
553581
);
554582
assert_contains_strings(&path, &["<del>strikethrough example</del>"]);

0 commit comments

Comments
 (0)