diff --git a/Cargo.lock b/Cargo.lock index 299e8daa53..3fb221bcef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1139,7 +1139,7 @@ dependencies = [ [[package]] name = "mdbook" -version = "0.4.42" +version = "0.4.43" dependencies = [ "ammonia", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 5d593b2700..04063a45a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 ", "Michael-F-Bryan ", diff --git a/guide/src/format/configuration/renderers.md b/guide/src/format/configuration/renderers.md index 5efede6606..84d615d205 100644 --- a/guide/src/format/configuration/renderers.md +++ b/guide/src/format/configuration/renderers.md @@ -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"] @@ -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. diff --git a/guide/src/format/markdown.md b/guide/src/format/markdown.md index f837d54c9c..cbe4d5a0fb 100644 --- a/guide/src/format/markdown.md +++ b/guide/src/format/markdown.md @@ -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. diff --git a/src/config.rs b/src/config.rs index b87ad27644..94ca1e5b2f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -531,6 +531,8 @@ pub struct HtmlConfig { pub preferred_dark_theme: Option, /// 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? @@ -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, diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index d0149fb523..1ac01094fa 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -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 { @@ -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 { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a53f79c0e9..b9f2912e75 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -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<'_> { @@ -210,11 +210,16 @@ 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| { @@ -222,8 +227,137 @@ pub fn render_markdown_with_path( 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##"{n}"##).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("
\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##"
"##.len() * usage_count), + ); + for usage in 1..=usage_count { + if usage == 1 { + end.push_str(&format!(r##" "##)); + } else { + end.push_str(&format!(r##" ↩{usage}"##)); + } + } + has_written_backrefs = true; + if f == Event::End(TagEnd::FootnoteDefinition) { + end.push_str("\n"); + } else { + end.push_str("

\n"); + } + Event::Html(end.into()) + } + Event::End(TagEnd::FootnoteDefinition) => Event::Html("\n".into()), + Event::FootnoteReference(_) => unreachable!("converted to HTML earlier"), + f => f, + }) + }), + ); + + // Closing div.footnotes + body.push_str("\n"); + } + + body } /// Wraps tables in a `.table-wrapper` class to apply overflow-x rules to. @@ -310,7 +444,7 @@ 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), "

example

\n" ); } @@ -318,17 +452,17 @@ mod tests { #[test] fn it_can_adjust_markdown_links() { assert_eq!( - render_markdown("[example](example.md)", false), + render_markdown("[example](example.md)", false, false), "

example

\n" ); assert_eq!( - render_markdown("[example_anchor](example.md#anchor)", false), + render_markdown("[example_anchor](example.md#anchor)", false, false), "

example_anchor

\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), "

phantom data

\n" ); } @@ -346,12 +480,12 @@ mod tests { "#.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), "

'one'

\n"); + assert_eq!(render_markdown("'one'", false, false), "

'one'

\n"); } #[test] @@ -367,7 +501,7 @@ mod tests {

'three' ‘four’

"#; - assert_eq!(render_markdown(input, true), expected); + assert_eq!(render_markdown(input, true, false), expected); } #[test] @@ -389,8 +523,8 @@ more text with spaces

more text with spaces

"#; - 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] @@ -402,8 +536,8 @@ more text with spaces let expected = r#"
"#; - 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] @@ -415,8 +549,8 @@ more text with spaces let expected = r#"
"#; - 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] @@ -428,15 +562,15 @@ more text with spaces let expected = r#"
"#; - 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); } }