Merge pull request #2626 from ehuss/footnote-backrefs-style
Add footnote backreferences, and update styling
This commit is contained in:
commit
a3c0ecdb45
6 changed files with 929 additions and 81 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
151
src/utils/mod.rs
151
src/utils/mod.rs
|
|
@ -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("<"),
|
||||
b'>' => escaped.push_str(">"),
|
||||
b'\'' => escaped.push_str("'"),
|
||||
b'"' => escaped.push_str("""),
|
||||
b'\\' => escaped.push_str("\"),
|
||||
b'&' => escaped.push_str("&"),
|
||||
_ => unreachable!(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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-"wacky"">
|
||||
<p>Testing footnote id with special characters. <a href="#fr-"wacky"-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
Loading…
Add table
Reference in a new issue