From 07425ced6281a8d8e0f97f552f0276e785c1d3e7 Mon Sep 17 00:00:00 2001 From: Gambhiro Date: Thu, 7 Nov 2024 19:13:35 +0000 Subject: [PATCH 1/3] footnote backreference links --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/config.rs | 3 + src/renderer/html_handlebars/hbs_renderer.rs | 9 +- src/utils/mod.rs | 182 ++++++++++++++++--- 5 files changed, 170 insertions(+), 28 deletions(-) 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/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..5bde489d5c 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -54,11 +54,14 @@ 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 { @@ -168,7 +171,9 @@ impl HtmlHandlebars { } }; let html_content_404 = - utils::render_markdown(&content_404, html_config.smart_punctuation()); + 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); } } From ac816958d2e565b3f1622e741ffce3a868d6ca97 Mon Sep 17 00:00:00 2001 From: Gambhiro Date: Thu, 7 Nov 2024 19:20:25 +0000 Subject: [PATCH 2/3] document footnote-backrefs option --- guide/src/format/configuration/renderers.md | 2 ++ guide/src/format/markdown.md | 7 +++++++ 2 files changed, 9 insertions(+) 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. From b22904488d57103e5d774386db5e6e9c2818be48 Mon Sep 17 00:00:00 2001 From: Gambhiro Date: Fri, 8 Nov 2024 05:14:17 +0000 Subject: [PATCH 3/3] cargo fmt --- src/renderer/html_handlebars/hbs_renderer.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 5bde489d5c..1ac01094fa 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -54,9 +54,11 @@ impl HtmlHandlebars { .insert("git_repository_edit_url".to_owned(), json!(edit_url)); } - let content = utils::render_markdown(&ch.content, - ctx.html_config.smart_punctuation(), - ctx.html_config.footnote_backrefs); + 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, @@ -170,10 +172,11 @@ impl HtmlHandlebars { .to_string() } }; - let html_content_404 = - utils::render_markdown(&content_404, - html_config.smart_punctuation(), - html_config.footnote_backrefs); + 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 {