diff --git a/components/config/src/config/markup.rs b/components/config/src/config/markup.rs index b1ac7fa22f..580a665ae6 100644 --- a/components/config/src/config/markup.rs +++ b/components/config/src/config/markup.rs @@ -43,6 +43,8 @@ pub struct Markdown { pub external_links_no_referrer: bool, /// Whether smart punctuation is enabled (changing quotes, dashes, dots etc in their typographic form) pub smart_punctuation: bool, + /// Whether footnotes are rendered at the bottom in the style of GitHub. + pub bottom_footnotes: bool, /// A list of directories to search for additional `.sublime-syntax` and `.tmTheme` files in. pub extra_syntaxes_and_themes: Vec, /// The compiled extra syntaxes into a syntax set @@ -203,6 +205,7 @@ impl Default for Markdown { external_links_no_follow: false, external_links_no_referrer: false, smart_punctuation: false, + bottom_footnotes: false, extra_syntaxes_and_themes: vec![], extra_syntax_set: None, extra_theme_set: Arc::new(None), diff --git a/components/markdown/src/markdown.rs b/components/markdown/src/markdown.rs index 40c94afac5..c15d2617d5 100644 --- a/components/markdown/src/markdown.rs +++ b/components/markdown/src/markdown.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; use std::fmt::Write; use errors::bail; +use crate::markdown::cmark::CowStr; use libs::gh_emoji::Replacer as EmojiReplacer; use libs::once_cell::sync::Lazy; use libs::pulldown_cmark as cmark; @@ -239,6 +241,159 @@ fn get_heading_refs(events: &[Event]) -> Vec { heading_refs } +fn fix_github_style_footnotes(old_events: &mut Vec) { + let events = std::mem::take(old_events); + // step 1: We need to extract footnotes from the event stream and tweak footnote references + + // footnotes bodies are stored in a stack of vectors, because it is possible to have footnotes + // inside footnotes + let mut footnote_bodies_stack = Vec::new(); + let mut footnotes = Vec::new(); + // this will allow to create a multiple back references + let mut footnote_numbers = HashMap::new(); + let filtered_events = events.into_iter().filter_map(|event| + match event { + // New footnote definition is pushed to the stack + Event::Start(Tag::FootnoteDefinition(_)) => { + footnote_bodies_stack.push(vec![event]); + None + } + // The topmost footnote definition is popped from the stack + Event::End(TagEnd::FootnoteDefinition) => { + // unwrap will newer fail, because Tag::FootnoteDefinition start always comes before + // TagEnd::FootnoteDefinition + let mut footnote_body = footnote_bodies_stack.pop().unwrap(); + footnote_body.push(event); + footnotes.push(footnote_body); + None + } + Event::FootnoteReference(name) => { + // n will be a unique index of the footnote + let n = footnote_numbers.len() + 1; + // nr is a number of references to this footnote + let (n, nr) = footnote_numbers.entry(name.clone()).or_insert((n, 0usize)); + *nr += 1; + let reference = Event::Html(format!(r##"[{n}]"##).into()); + + if footnote_bodies_stack.is_empty() { + // we are in the main text, just output the reference + Some(reference) + } else { + // we are inside other footnote, we have to push that reference into that + // footnote + footnote_bodies_stack.last_mut().unwrap().push(reference); + None + } + } + _ if !footnote_bodies_stack.is_empty() => { + footnote_bodies_stack.last_mut().unwrap().push(event); + None + } + _ => Some(event), + } + ); + + old_events.extend(filtered_events); + + if footnotes.is_empty() { + return; + } + + old_events.push(Event::Html("
    \n".into())); + + // Step 2: retain only footnotes which was actually referenced + footnotes.retain(|f| match f.first() { + Some(Event::Start(Tag::FootnoteDefinition(name))) => { + footnote_numbers.get(name).unwrap_or(&(0, 0)).1 != 0 + } + _ => false, + }); + + // Step 3: Sort footnotes in the order of their appearance + 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!(), + }); + + // Step 4: Add backreferences to footnotes + let footnotes = 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(""); + // Backrefs are included in the final paragraph of the footnote, if it's normal text. + // For example, this DOM can be produced: + // + // Markdown: + // + // five [^feet]. + // + // [^feet]: + // A foot is defined, in this case, as 0.3048 m. + // + // Historically, the foot has not been defined this way, corresponding to many + // subtly different units depending on the location. + // + // HTML: + // + //

    five [1].

    + // + //
      + //
    1. + //

      A foot is defined, in this case, as 0.3048 m.

      + //

      Historically, the foot has not been defined this way, corresponding to many + // subtly different units depending on the location.

      + //
    2. + //
    + // + // This is mostly a visual hack, so that footnotes use less vertical space. + // + // If there is no final paragraph, such as a tabular, list, or image footnote, it gets + // pushed after the last tag instead. + 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; + has_written_backrefs = false; + Event::Html(format!(r##"
  1. "##).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##"
  2. "##.len() * usage_count), + ); + for usage in 1..=usage_count { + if usage == 1 { + write!(&mut end, r##" "##) + .unwrap(); + } else { + write!(&mut end, r##" ↩{usage}"##) + .unwrap(); + } + } + 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, + }) + + }); + + old_events.extend(footnotes); + old_events.push(Event::Html("
\n".into())); +} + pub fn markdown_to_html( content: &str, context: &RenderContext, @@ -623,6 +778,10 @@ pub fn markdown_to_html( insert_many(&mut events, anchors_to_insert); } + if context.config.markdown.bottom_footnotes { + fix_github_style_footnotes(&mut events); + } + cmark::html::push_html(&mut html, events.into_iter()); }