Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

footnote backreference links #2475

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = [".", "examples/remove-emphasis/mdbook-remove-emphasis"]

[package]
name = "mdbook"
version = "0.4.42"
version = "0.4.43"
authors = [
"Mathieu David <mathieudavid@mathieudavid.org>",
"Michael-F-Bryan <michaelfbryan@gmail.com>",
Expand Down
2 changes: 2 additions & 0 deletions guide/src/format/configuration/renderers.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ theme = "my-theme"
default-theme = "light"
preferred-dark-theme = "navy"
smart-punctuation = true
footnote-backrefs = true
mathjax-support = false
copy-fonts = true
additional-css = ["custom.css", "custom2.css"]
Expand Down Expand Up @@ -126,6 +127,7 @@ The following configuration options are available:
See [Smart Punctuation](../markdown.md#smart-punctuation).
Defaults to `false`.
- **curly-quotes:** Deprecated alias for `smart-punctuation`.
- **footnote-backrefs:** Add backreference links to footnote definitions.
- **mathjax-support:** Adds support for [MathJax](../mathjax.md). Defaults to
`false`.
- **copy-fonts:** (**Deprecated**) If `true` (the default), mdBook uses its built-in fonts which are copied to the output directory.
Expand Down
7 changes: 7 additions & 0 deletions guide/src/format/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,13 @@ To enable it, see the [`output.html.smart-punctuation`] config option.
[task list extension]: https://github.github.com/gfm/#task-list-items-extension-
[`output.html.smart-punctuation`]: configuration/renderers.md#html-renderer-options

### Footnote backreference links

Add backreference links to footnote definitions.

This feature is disabled by default.
To enable it, see the [`output.html.footnote-backrefs`] config option.

### Heading attributes

Headings can have a custom HTML ID and classes. This lets you maintain the same ID even if you change the heading's text, it also lets you add multiple classes in the heading.
Expand Down
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,8 @@ pub struct HtmlConfig {
pub preferred_dark_theme: Option<String>,
/// Supports smart quotes, apostrophes, ellipsis, en-dash, and em-dash.
pub smart_punctuation: bool,
/// Add backreference links to footnote definitions.
pub footnote_backrefs: bool,
/// Deprecated alias for `smart_punctuation`.
pub curly_quotes: bool,
/// Should mathjax be enabled?
Expand Down Expand Up @@ -596,6 +598,7 @@ impl Default for HtmlConfig {
default_theme: None,
preferred_dark_theme: None,
smart_punctuation: false,
footnote_backrefs: false,
curly_quotes: false,
mathjax_support: false,
copy_fonts: true,
Expand Down
14 changes: 11 additions & 3 deletions src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,16 @@ impl HtmlHandlebars {
.insert("git_repository_edit_url".to_owned(), json!(edit_url));
}

let content = utils::render_markdown(&ch.content, ctx.html_config.smart_punctuation());
let content = utils::render_markdown(
&ch.content,
ctx.html_config.smart_punctuation(),
ctx.html_config.footnote_backrefs,
);

let fixed_content = utils::render_markdown_with_path(
&ch.content,
ctx.html_config.smart_punctuation(),
ctx.html_config.footnote_backrefs,
Some(path),
);
if !ctx.is_index && ctx.html_config.print.page_break {
Expand Down Expand Up @@ -167,8 +172,11 @@ impl HtmlHandlebars {
.to_string()
}
};
let html_content_404 =
utils::render_markdown(&content_404, html_config.smart_punctuation());
let html_content_404 = utils::render_markdown(
&content_404,
html_config.smart_punctuation(),
html_config.footnote_backrefs,
);

let mut data_404 = data.clone();
let base_url = if let Some(site_url) = &html_config.site_url {
Expand Down
182 changes: 158 additions & 24 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@ fn adjust_links<'a>(event: Event<'a>, path: Option<&Path>) -> Event<'a> {
}

/// Wrapper around the pulldown-cmark parser for rendering markdown to HTML.
pub fn render_markdown(text: &str, smart_punctuation: bool) -> String {
render_markdown_with_path(text, smart_punctuation, None)
pub fn render_markdown(text: &str, smart_punctuation: bool, footnote_backrefs: bool) -> String {
render_markdown_with_path(text, smart_punctuation, footnote_backrefs, None)
}

pub fn new_cmark_parser(text: &str, smart_punctuation: bool) -> Parser<'_> {
Expand All @@ -210,20 +210,154 @@ pub fn new_cmark_parser(text: &str, smart_punctuation: bool) -> Parser<'_> {
pub fn render_markdown_with_path(
text: &str,
smart_punctuation: bool,
footnote_backrefs: 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
if footnote_backrefs {
return render_markdown_with_path_with_footnote_backrefs(text, smart_punctuation, path);
}

let mut body = String::with_capacity(text.len() * 3 / 2);
let parser = new_cmark_parser(text, smart_punctuation);
let events = parser
.map(clean_codeblock_headers)
.map(|event| adjust_links(event, path))
.flat_map(|event| {
let (a, b) = wrap_tables(event);
a.into_iter().chain(b)
});

html::push_html(&mut s, events);
s
html::push_html(&mut body, events);
body
}

pub fn render_markdown_with_path_with_footnote_backrefs(
text: &str,
smart_punctuation: bool,
path: Option<&Path>,
) -> String {
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

// To generate this style, you have to collect the footnotes at the end, while parsing.
// You also need to count usages.
let mut footnotes = Vec::new();
let mut in_footnote = Vec::new();
let mut footnote_numbers = HashMap::new();

let parser = new_cmark_parser(text, smart_punctuation)
.filter_map(|event| {
match event {
Event::Start(Tag::FootnoteDefinition(_)) => {
in_footnote.push(vec![event]);
None
}
Event::End(TagEnd::FootnoteDefinition) => {
let mut f = in_footnote.pop().unwrap();
f.push(event);
footnotes.push(f);
None
}
Event::FootnoteReference(name) => {
let n = footnote_numbers.len() + 1;
let (n, nr) = footnote_numbers.entry(name.clone()).or_insert((n, 0usize));
*nr += 1;
let html = Event::Html(format!(r##"<sup class="footnote-reference" id="fr-{name}-{nr}"><a href="#fn-{name}">{n}</a></sup>"##).into());
if in_footnote.is_empty() {
Some(html)
} else {
in_footnote.last_mut().unwrap().push(html);
None
}
}
_ if !in_footnote.is_empty() => {
in_footnote.last_mut().unwrap().push(event);
None
}
_ => Some(event),
}
});

let events = parser
.map(clean_codeblock_headers)
.map(|event| adjust_links(event, path))
.flat_map(|event| {
let (a, b) = wrap_tables(event);
a.into_iter().chain(b)
});

html::push_html(&mut body, events);

// To make the footnotes look right, we need to sort them by their appearance order, not by
// the in-tree order of their actual definitions. Unused items are omitted entirely.
if !footnotes.is_empty() {
footnotes.retain(|f| match f.first() {
Some(Event::Start(Tag::FootnoteDefinition(name))) => {
footnote_numbers.get(name).unwrap_or(&(0, 0)).1 != 0
}
_ => false,
});
footnotes.sort_by_cached_key(|f| match f.first() {
Some(Event::Start(Tag::FootnoteDefinition(name))) => {
footnote_numbers.get(name).unwrap_or(&(0, 0)).0
}
_ => unreachable!(),
});

body.push_str("<div class=\"footnotes\">\n");

html::push_html(
&mut body,
footnotes.into_iter().flat_map(|fl| {
// To write backrefs, the name needs kept until the end of the footnote definition.
let mut name = CowStr::from("");

let mut has_written_backrefs = false;
let fl_len = fl.len();
let footnote_numbers = &footnote_numbers;
fl.into_iter().enumerate().map(move |(i, f)| match f {
Event::Start(Tag::FootnoteDefinition(current_name)) => {
name = current_name;
let fn_number = footnote_numbers.get(&name).unwrap().0;
has_written_backrefs = false;
Event::Html(format!(r##"<aside class="footnote-definition" id="fn-{name}"><sup class="footnote-definition-label">{fn_number}</sup>"##).into())
}
Event::End(TagEnd::FootnoteDefinition) | Event::End(TagEnd::Paragraph)
if !has_written_backrefs && i >= fl_len - 2 =>
{
let usage_count = footnote_numbers.get(&name).unwrap().1;
let mut end = String::with_capacity(
name.len() + (r##" <a href="#fr--1">↩</a></div>"##.len() * usage_count),
);
for usage in 1..=usage_count {
if usage == 1 {
end.push_str(&format!(r##" <a href="#fr-{name}-{usage}">↩</a>"##));
} else {
end.push_str(&format!(r##" <a href="#fr-{name}-{usage}">↩{usage}</a>"##));
}
}
has_written_backrefs = true;
if f == Event::End(TagEnd::FootnoteDefinition) {
end.push_str("</aside>\n");
} else {
end.push_str("</p>\n");
}
Event::Html(end.into())
}
Event::End(TagEnd::FootnoteDefinition) => Event::Html("</aside>\n".into()),
Event::FootnoteReference(_) => unreachable!("converted to HTML earlier"),
f => f,
})
}),
);

// Closing div.footnotes
body.push_str("</div>\n");
}

body
}

/// Wraps tables in a `.table-wrapper` class to apply overflow-x rules to.
Expand Down Expand Up @@ -310,25 +444,25 @@ mod tests {
#[test]
fn preserves_external_links() {
assert_eq!(
render_markdown("[example](https://www.rust-lang.org/)", false),
render_markdown("[example](https://www.rust-lang.org/)", false, false),
"<p><a href=\"https://www.rust-lang.org/\">example</a></p>\n"
);
}

#[test]
fn it_can_adjust_markdown_links() {
assert_eq!(
render_markdown("[example](example.md)", false),
render_markdown("[example](example.md)", false, false),
"<p><a href=\"example.html\">example</a></p>\n"
);
assert_eq!(
render_markdown("[example_anchor](example.md#anchor)", false),
render_markdown("[example_anchor](example.md#anchor)", false, false),
"<p><a href=\"example.html#anchor\">example_anchor</a></p>\n"
);

// this anchor contains 'md' inside of it
assert_eq!(
render_markdown("[phantom data](foo.html#phantomdata)", false),
render_markdown("[phantom data](foo.html#phantomdata)", false, false),
"<p><a href=\"foo.html#phantomdata\">phantom data</a></p>\n"
);
}
Expand All @@ -346,12 +480,12 @@ mod tests {
</tbody></table>
</div>
"#.trim();
assert_eq!(render_markdown(src, false), out);
assert_eq!(render_markdown(src, false, false), out);
}

#[test]
fn it_can_keep_quotes_straight() {
assert_eq!(render_markdown("'one'", false), "<p>'one'</p>\n");
assert_eq!(render_markdown("'one'", false, false), "<p>'one'</p>\n");
}

#[test]
Expand All @@ -367,7 +501,7 @@ mod tests {
</code></pre>
<p><code>'three'</code> ‘four’</p>
"#;
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, true, false), expected);
}

#[test]
Expand All @@ -389,8 +523,8 @@ more text with spaces
</code></pre>
<p>more text with spaces</p>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, false, false), expected);
assert_eq!(render_markdown(input, true, false), expected);
}

#[test]
Expand All @@ -402,8 +536,8 @@ more text with spaces

let expected = r#"<pre><code class="language-rust,no_run,should_panic,property_3"></code></pre>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, false, false), expected);
assert_eq!(render_markdown(input, true, false), expected);
}

#[test]
Expand All @@ -415,8 +549,8 @@ more text with spaces

let expected = r#"<pre><code class="language-rust,,,,,no_run,,,should_panic,,,,property_3"></code></pre>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, false, false), expected);
assert_eq!(render_markdown(input, true, false), expected);
}

#[test]
Expand All @@ -428,15 +562,15 @@ more text with spaces

let expected = r#"<pre><code class="language-rust"></code></pre>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, false, false), expected);
assert_eq!(render_markdown(input, true, false), expected);

let input = r#"
```rust
```
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, false, false), expected);
assert_eq!(render_markdown(input, true, false), expected);
}
}

Expand Down