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;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:not(.footnote-definition) + .footnote-definition {
|
|
||||||
margin-block-start: 2em;
|
|
||||||
}
|
|
||||||
.footnote-definition:not(:has(+ .footnote-definition)) {
|
|
||||||
margin-block-end: 2em;
|
|
||||||
}
|
|
||||||
.footnote-definition {
|
.footnote-definition {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
}
|
||||||
.footnote-definition p {
|
/* The default spacing for a list is a little too large. */
|
||||||
display: inline;
|
.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 {
|
.tooltiptext {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@
|
||||||
--copy-button-filter: invert(45%) sepia(6%) saturate(621%) hue-rotate(198deg) brightness(99%) contrast(85%);
|
--copy-button-filter: invert(45%) sepia(6%) saturate(621%) hue-rotate(198deg) brightness(99%) contrast(85%);
|
||||||
/* Same as `--sidebar-active` */
|
/* Same as `--sidebar-active` */
|
||||||
--copy-button-filter-hover: invert(68%) sepia(55%) saturate(531%) hue-rotate(341deg) brightness(104%) contrast(101%);
|
--copy-button-filter-hover: invert(68%) sepia(55%) saturate(531%) hue-rotate(341deg) brightness(104%) contrast(101%);
|
||||||
|
|
||||||
|
--footnote-highlight: #2668a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coal {
|
.coal {
|
||||||
|
|
@ -110,6 +112,8 @@
|
||||||
--copy-button-filter: invert(26%) sepia(8%) saturate(575%) hue-rotate(169deg) brightness(87%) contrast(82%);
|
--copy-button-filter: invert(26%) sepia(8%) saturate(575%) hue-rotate(169deg) brightness(87%) contrast(82%);
|
||||||
/* Same as `--sidebar-active` */
|
/* Same as `--sidebar-active` */
|
||||||
--copy-button-filter-hover: invert(36%) sepia(70%) saturate(503%) hue-rotate(167deg) brightness(98%) contrast(89%);
|
--copy-button-filter-hover: invert(36%) sepia(70%) saturate(503%) hue-rotate(167deg) brightness(98%) contrast(89%);
|
||||||
|
|
||||||
|
--footnote-highlight: #4079ae;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light, html:not(.js) {
|
.light, html:not(.js) {
|
||||||
|
|
@ -159,6 +163,8 @@
|
||||||
--copy-button-filter: invert(45.49%);
|
--copy-button-filter: invert(45.49%);
|
||||||
/* Same as `--sidebar-active` */
|
/* Same as `--sidebar-active` */
|
||||||
--copy-button-filter-hover: invert(14%) sepia(93%) saturate(4250%) hue-rotate(243deg) brightness(99%) contrast(130%);
|
--copy-button-filter-hover: invert(14%) sepia(93%) saturate(4250%) hue-rotate(243deg) brightness(99%) contrast(130%);
|
||||||
|
|
||||||
|
--footnote-highlight: #7e7eff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navy {
|
.navy {
|
||||||
|
|
@ -208,6 +214,8 @@
|
||||||
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
|
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
|
||||||
/* Same as `--sidebar-active` */
|
/* Same as `--sidebar-active` */
|
||||||
--copy-button-filter-hover: invert(46%) sepia(20%) saturate(1537%) hue-rotate(156deg) brightness(85%) contrast(90%);
|
--copy-button-filter-hover: invert(46%) sepia(20%) saturate(1537%) hue-rotate(156deg) brightness(85%) contrast(90%);
|
||||||
|
|
||||||
|
--footnote-highlight: #4079ae;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rust {
|
.rust {
|
||||||
|
|
@ -255,6 +263,8 @@
|
||||||
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
|
--copy-button-filter: invert(51%) sepia(10%) saturate(393%) hue-rotate(198deg) brightness(86%) contrast(87%);
|
||||||
/* Same as `--sidebar-active` */
|
/* Same as `--sidebar-active` */
|
||||||
--copy-button-filter-hover: invert(77%) sepia(16%) saturate(1798%) hue-rotate(328deg) brightness(98%) contrast(83%);
|
--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) {
|
@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,
|
smart_punctuation: bool,
|
||||||
path: Option<&Path>,
|
path: Option<&Path>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut s = String::with_capacity(text.len() * 3 / 2);
|
let mut body = String::with_capacity(text.len() * 3 / 2);
|
||||||
let p = new_cmark_parser(text, smart_punctuation);
|
|
||||||
let events = p
|
// 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(clean_codeblock_headers)
|
||||||
.map(|event| adjust_links(event, path))
|
.map(|event| adjust_links(event, path))
|
||||||
.flat_map(|event| {
|
.flat_map(|event| {
|
||||||
let (a, b) = wrap_tables(event);
|
let (a, b) = wrap_tables(event);
|
||||||
a.into_iter().chain(b)
|
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);
|
html::push_html(&mut body, events);
|
||||||
s
|
|
||||||
|
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.
|
/// 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 {
|
pub(crate) fn special_escape(mut s: &str) -> String {
|
||||||
let mut escaped = String::with_capacity(s.len());
|
let mut escaped = String::with_capacity(s.len());
|
||||||
let needs_escape: &[char] = &['<', '>', '\'', '\\', '&'];
|
let needs_escape: &[char] = &['<', '>', '\'', '"', '\\', '&'];
|
||||||
while let Some(next) = s.find(needs_escape) {
|
while let Some(next) = s.find(needs_escape) {
|
||||||
escaped.push_str(&s[..next]);
|
escaped.push_str(&s[..next]);
|
||||||
match s.as_bytes()[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("'"),
|
||||||
|
b'"' => escaped.push_str("""),
|
||||||
b'\\' => escaped.push_str("\"),
|
b'\\' => escaped.push_str("\"),
|
||||||
b'&' => escaped.push_str("&"),
|
b'&' => escaped.push_str("&"),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,34 @@ Footnote example[^1], or with a word[^word].
|
||||||
[^1]: This is a footnote.
|
[^1]: This is a footnote.
|
||||||
|
|
||||||
[^word]: A longer footnote.
|
[^word]: A longer footnote.
|
||||||
With multiple lines.
|
With multiple lines. [Link to unicode](unicode.md).
|
||||||
Third line.
|
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
|
## Strikethrough
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -545,10 +545,38 @@ fn markdown_options() {
|
||||||
assert_contains_strings(
|
assert_contains_strings(
|
||||||
&path,
|
&path,
|
||||||
&[
|
&[
|
||||||
r##"<sup class="footnote-reference"><a href="#1">1</a></sup>"##,
|
r##"<sup class="footnote-reference" id="fr-1-1"><a href="#footnote-1">1</a></sup>"##,
|
||||||
r##"<sup class="footnote-reference"><a href="#word">2</a></sup>"##,
|
r##"<sup class="footnote-reference" id="fr-word-1"><a href="#footnote-word">2</a></sup>"##,
|
||||||
r##"<div class="footnote-definition" id="1"><sup class="footnote-definition-label">1</sup>"##,
|
r##"<sup class="footnote-reference" id="fr-word-2"><a href="#footnote-word">2</a></sup>"##,
|
||||||
r##"<div class="footnote-definition" id="word"><sup class="footnote-definition-label">2</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>"]);
|
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