Skip to content

Commit ff7a6a4

Browse files
totikomberdandy
authored andcommitted
Implemented bottom footnotes with backreferences (getzola#2480)
* Implemented bottom footnotes with backreferences Fixes getzola#1285 * Added bottom_footnotes option to configuration.md * Renamed fix_github_style_footnotes() * Added tests for convert_footnotes_to_github_style() * Changed test to plain html instead of Vec<Event> * Added integration test for footnotes * Applied suggested changes
1 parent def7516 commit ff7a6a4

11 files changed

+403
-2
lines changed

components/config/src/config/markup.rs

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub struct Markdown {
4343
pub external_links_no_referrer: bool,
4444
/// Whether smart punctuation is enabled (changing quotes, dashes, dots etc in their typographic form)
4545
pub smart_punctuation: bool,
46+
/// Whether footnotes are rendered at the bottom in the style of GitHub.
47+
pub bottom_footnotes: bool,
4648
/// A list of directories to search for additional `.sublime-syntax` and `.tmTheme` files in.
4749
pub extra_syntaxes_and_themes: Vec<String>,
4850
/// The compiled extra syntaxes into a syntax set
@@ -203,6 +205,7 @@ impl Default for Markdown {
203205
external_links_no_follow: false,
204206
external_links_no_referrer: false,
205207
smart_punctuation: false,
208+
bottom_footnotes: false,
206209
extra_syntaxes_and_themes: vec![],
207210
extra_syntax_set: None,
208211
extra_theme_set: Arc::new(None),

components/markdown/src/markdown.rs

+262-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
use std::collections::HashMap;
12
use std::fmt::Write;
23

4+
use crate::markdown::cmark::CowStr;
35
use errors::bail;
46
use libs::gh_emoji::Replacer as EmojiReplacer;
57
use libs::once_cell::sync::Lazy;
@@ -239,6 +241,158 @@ fn get_heading_refs(events: &[Event]) -> Vec<HeadingRef> {
239241
heading_refs
240242
}
241243

244+
fn convert_footnotes_to_github_style(old_events: &mut Vec<Event>) {
245+
let events = std::mem::take(old_events);
246+
// step 1: We need to extract footnotes from the event stream and tweak footnote references
247+
248+
// footnotes bodies are stored in a stack of vectors, because it is possible to have footnotes
249+
// inside footnotes
250+
let mut footnote_bodies_stack = Vec::new();
251+
let mut footnotes = Vec::new();
252+
// this will allow to create a multiple back references
253+
let mut footnote_numbers = HashMap::new();
254+
let filtered_events = events.into_iter().filter_map(|event| {
255+
match event {
256+
// New footnote definition is pushed to the stack
257+
Event::Start(Tag::FootnoteDefinition(_)) => {
258+
footnote_bodies_stack.push(vec![event]);
259+
None
260+
}
261+
// The topmost footnote definition is popped from the stack
262+
Event::End(TagEnd::FootnoteDefinition) => {
263+
// unwrap will never fail, because Tag::FootnoteDefinition always comes before
264+
// TagEnd::FootnoteDefinition
265+
let mut footnote_body = footnote_bodies_stack.pop().unwrap();
266+
footnote_body.push(event);
267+
footnotes.push(footnote_body);
268+
None
269+
}
270+
Event::FootnoteReference(name) => {
271+
// n will be a unique index of the footnote
272+
let n = footnote_numbers.len() + 1;
273+
// nr is a number of references to this footnote
274+
let (n, nr) = footnote_numbers.entry(name.clone()).or_insert((n, 0usize));
275+
*nr += 1;
276+
let reference = Event::Html(format!(r##"<sup class="footnote-reference" id="fr-{name}-{nr}"><a href="#fn-{name}">[{n}]</a></sup>"##).into());
277+
278+
if footnote_bodies_stack.is_empty() {
279+
// we are in the main text, just output the reference
280+
Some(reference)
281+
} else {
282+
// we are inside other footnote, we have to push that reference into that
283+
// footnote
284+
footnote_bodies_stack.last_mut().unwrap().push(reference);
285+
None
286+
}
287+
}
288+
_ if !footnote_bodies_stack.is_empty() => {
289+
footnote_bodies_stack.last_mut().unwrap().push(event);
290+
None
291+
}
292+
_ => Some(event),
293+
}
294+
}
295+
);
296+
297+
old_events.extend(filtered_events);
298+
299+
if footnotes.is_empty() {
300+
return;
301+
}
302+
303+
old_events.push(Event::Html("<hr><ol class=\"footnotes-list\">\n".into()));
304+
305+
// Step 2: retain only footnotes which was actually referenced
306+
footnotes.retain(|f| match f.first() {
307+
Some(Event::Start(Tag::FootnoteDefinition(name))) => {
308+
footnote_numbers.get(name).unwrap_or(&(0, 0)).1 != 0
309+
}
310+
_ => false,
311+
});
312+
313+
// Step 3: Sort footnotes in the order of their appearance
314+
footnotes.sort_by_cached_key(|f| match f.first() {
315+
Some(Event::Start(Tag::FootnoteDefinition(name))) => {
316+
footnote_numbers.get(name).unwrap_or(&(0, 0)).0
317+
}
318+
_ => unreachable!(),
319+
});
320+
321+
// Step 4: Add backreferences to footnotes
322+
let footnotes = footnotes.into_iter().flat_map(|fl| {
323+
// To write backrefs, the name needs kept until the end of the footnote definition.
324+
let mut name = CowStr::from("");
325+
// Backrefs are included in the final paragraph of the footnote, if it's normal text.
326+
// For example, this DOM can be produced:
327+
//
328+
// Markdown:
329+
//
330+
// five [^feet].
331+
//
332+
// [^feet]:
333+
// A foot is defined, in this case, as 0.3048 m.
334+
//
335+
// Historically, the foot has not been defined this way, corresponding to many
336+
// subtly different units depending on the location.
337+
//
338+
// HTML:
339+
//
340+
// <p>five <sup class="footnote-reference" id="fr-feet-1"><a href="#fn-feet">[1]</a></sup>.</p>
341+
//
342+
// <ol class="footnotes-list">
343+
// <li id="fn-feet">
344+
// <p>A foot is defined, in this case, as 0.3048 m.</p>
345+
// <p>Historically, the foot has not been defined this way, corresponding to many
346+
// subtly different units depending on the location. <a href="#fr-feet-1">↩</a></p>
347+
// </li>
348+
// </ol>
349+
//
350+
// This is mostly a visual hack, so that footnotes use less vertical space.
351+
//
352+
// If there is no final paragraph, such as a tabular, list, or image footnote, it gets
353+
// pushed after the last tag instead.
354+
let mut has_written_backrefs = false;
355+
let fl_len = fl.len();
356+
let footnote_numbers = &footnote_numbers;
357+
fl.into_iter().enumerate().map(move |(i, f)| match f {
358+
Event::Start(Tag::FootnoteDefinition(current_name)) => {
359+
name = current_name;
360+
has_written_backrefs = false;
361+
Event::Html(format!(r##"<li id="fn-{name}">"##).into())
362+
}
363+
Event::End(TagEnd::FootnoteDefinition) | Event::End(TagEnd::Paragraph)
364+
if !has_written_backrefs && i >= fl_len - 2 =>
365+
{
366+
let usage_count = footnote_numbers.get(&name).unwrap().1;
367+
let mut end = String::with_capacity(
368+
name.len() + (r##" <a href="#fr--1">↩</a></li>"##.len() * usage_count),
369+
);
370+
for usage in 1..=usage_count {
371+
if usage == 1 {
372+
write!(&mut end, r##" <a href="#fr-{name}-{usage}">↩</a>"##).unwrap();
373+
} else {
374+
write!(&mut end, r##" <a href="#fr-{name}-{usage}">↩{usage}</a>"##)
375+
.unwrap();
376+
}
377+
}
378+
has_written_backrefs = true;
379+
if f == Event::End(TagEnd::FootnoteDefinition) {
380+
end.push_str("</li>\n");
381+
} else {
382+
end.push_str("</p>\n");
383+
}
384+
Event::Html(end.into())
385+
}
386+
Event::End(TagEnd::FootnoteDefinition) => Event::Html("</li>\n".into()),
387+
Event::FootnoteReference(_) => unreachable!("converted to HTML earlier"),
388+
f => f,
389+
})
390+
});
391+
392+
old_events.extend(footnotes);
393+
old_events.push(Event::Html("</ol>\n".into()));
394+
}
395+
242396
pub fn markdown_to_html(
243397
content: &str,
244398
context: &RenderContext,
@@ -623,6 +777,10 @@ pub fn markdown_to_html(
623777
insert_many(&mut events, anchors_to_insert);
624778
}
625779

780+
if context.config.markdown.bottom_footnotes {
781+
convert_footnotes_to_github_style(&mut events);
782+
}
783+
626784
cmark::html::push_html(&mut html, events.into_iter());
627785
}
628786

@@ -641,11 +799,11 @@ pub fn markdown_to_html(
641799

642800
#[cfg(test)]
643801
mod tests {
802+
use super::*;
644803
use config::Config;
804+
use insta::assert_snapshot;
645805

646-
use super::*;
647806
#[test]
648-
649807
fn insert_many_works() {
650808
let mut v = vec![1, 2, 3, 4, 5];
651809
insert_many(&mut v, vec![(0, 0), (2, -1), (5, 6)]);
@@ -714,4 +872,106 @@ mod tests {
714872
assert_eq!(body, &bottom_rendered);
715873
}
716874
}
875+
876+
#[test]
877+
fn no_footnotes() {
878+
let mut opts = Options::empty();
879+
opts.insert(Options::ENABLE_TABLES);
880+
opts.insert(Options::ENABLE_FOOTNOTES);
881+
opts.insert(Options::ENABLE_STRIKETHROUGH);
882+
opts.insert(Options::ENABLE_TASKLISTS);
883+
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
884+
885+
let content = "Some text *without* footnotes.\n\nOnly ~~fancy~~ formatting.";
886+
let mut events: Vec<_> = Parser::new_ext(&content, opts).collect();
887+
convert_footnotes_to_github_style(&mut events);
888+
let mut html = String::new();
889+
cmark::html::push_html(&mut html, events.into_iter());
890+
assert_snapshot!(html);
891+
}
892+
893+
#[test]
894+
fn single_footnote() {
895+
let mut opts = Options::empty();
896+
opts.insert(Options::ENABLE_TABLES);
897+
opts.insert(Options::ENABLE_FOOTNOTES);
898+
opts.insert(Options::ENABLE_STRIKETHROUGH);
899+
opts.insert(Options::ENABLE_TASKLISTS);
900+
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
901+
902+
let content = "This text has a footnote[^1]\n [^1]:But it is meaningless.";
903+
let mut events: Vec<_> = Parser::new_ext(&content, opts).collect();
904+
convert_footnotes_to_github_style(&mut events);
905+
let mut html = String::new();
906+
cmark::html::push_html(&mut html, events.into_iter());
907+
assert_snapshot!(html);
908+
}
909+
910+
#[test]
911+
fn reordered_footnotes() {
912+
let mut opts = Options::empty();
913+
opts.insert(Options::ENABLE_TABLES);
914+
opts.insert(Options::ENABLE_FOOTNOTES);
915+
opts.insert(Options::ENABLE_STRIKETHROUGH);
916+
opts.insert(Options::ENABLE_TASKLISTS);
917+
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
918+
919+
let content = "This text has two[^2] footnotes[^1]\n[^1]: not sorted.\n[^2]: But they are";
920+
let mut events: Vec<_> = Parser::new_ext(&content, opts).collect();
921+
convert_footnotes_to_github_style(&mut events);
922+
let mut html = String::new();
923+
cmark::html::push_html(&mut html, events.into_iter());
924+
assert_snapshot!(html);
925+
}
926+
927+
#[test]
928+
fn def_before_use() {
929+
let mut opts = Options::empty();
930+
opts.insert(Options::ENABLE_TABLES);
931+
opts.insert(Options::ENABLE_FOOTNOTES);
932+
opts.insert(Options::ENABLE_STRIKETHROUGH);
933+
opts.insert(Options::ENABLE_TASKLISTS);
934+
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
935+
936+
let content = "[^1]:It's before the reference.\n\n There is footnote definition?[^1]";
937+
let mut events: Vec<_> = Parser::new_ext(&content, opts).collect();
938+
convert_footnotes_to_github_style(&mut events);
939+
let mut html = String::new();
940+
cmark::html::push_html(&mut html, events.into_iter());
941+
assert_snapshot!(html);
942+
}
943+
944+
#[test]
945+
fn multiple_refs() {
946+
let mut opts = Options::empty();
947+
opts.insert(Options::ENABLE_TABLES);
948+
opts.insert(Options::ENABLE_FOOTNOTES);
949+
opts.insert(Options::ENABLE_STRIKETHROUGH);
950+
opts.insert(Options::ENABLE_TASKLISTS);
951+
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
952+
953+
let content = "This text has two[^1] identical footnotes[^1]\n[^1]: So one is present.\n[^2]: But another in not.";
954+
let mut events: Vec<_> = Parser::new_ext(&content, opts).collect();
955+
convert_footnotes_to_github_style(&mut events);
956+
let mut html = String::new();
957+
cmark::html::push_html(&mut html, events.into_iter());
958+
assert_snapshot!(html);
959+
}
960+
961+
#[test]
962+
fn footnote_inside_footnote() {
963+
let mut opts = Options::empty();
964+
opts.insert(Options::ENABLE_TABLES);
965+
opts.insert(Options::ENABLE_FOOTNOTES);
966+
opts.insert(Options::ENABLE_STRIKETHROUGH);
967+
opts.insert(Options::ENABLE_TASKLISTS);
968+
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
969+
970+
let content = "This text has a footnote[^1]\n[^1]: But the footnote has another footnote[^2].\n[^2]: That's it.";
971+
let mut events: Vec<_> = Parser::new_ext(&content, opts).collect();
972+
convert_footnotes_to_github_style(&mut events);
973+
let mut html = String::new();
974+
cmark::html::push_html(&mut html, events.into_iter());
975+
assert_snapshot!(html);
976+
}
717977
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: components/markdown/src/markdown.rs
3+
expression: html
4+
---
5+
<p>There is footnote definition?<sup class="footnote-reference" id="fr-1-1"><a href="#fn-1">[1]</a></sup></p>
6+
<hr><ol class="footnotes-list">
7+
<li id="fn-1">
8+
<p>It's before the reference. <a href="#fr-1-1">↩</a></p>
9+
</li>
10+
</ol>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
source: components/markdown/src/markdown.rs
3+
expression: html
4+
---
5+
<p>This text has a footnote<sup class="footnote-reference" id="fr-1-1"><a href="#fn-1">[1]</a></sup></p>
6+
<hr><ol class="footnotes-list">
7+
<li id="fn-1">
8+
<p>But the footnote has another footnote<sup class="footnote-reference" id="fr-2-1"><a href="#fn-2">[2]</a></sup>. <a href="#fr-1-1">↩</a></p>
9+
</li>
10+
<li id="fn-2">
11+
<p>That's it. <a href="#fr-2-1">↩</a></p>
12+
</li>
13+
</ol>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: components/markdown/src/markdown.rs
3+
expression: html
4+
---
5+
<p>This text has two<sup class="footnote-reference" id="fr-1-1"><a href="#fn-1">[1]</a></sup> identical footnotes<sup class="footnote-reference" id="fr-1-2"><a href="#fn-1">[1]</a></sup></p>
6+
<hr><ol class="footnotes-list">
7+
<li id="fn-1">
8+
<p>So one is present. <a href="#fr-1-1">↩</a> <a href="#fr-1-2">↩2</a></p>
9+
</li>
10+
</ol>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: components/markdown/src/markdown.rs
3+
expression: html
4+
---
5+
<p>Some text <em>without</em> footnotes.</p>
6+
<p>Only <del>fancy</del> formatting.</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
source: components/markdown/src/markdown.rs
3+
expression: html
4+
---
5+
<p>This text has two<sup class="footnote-reference" id="fr-2-1"><a href="#fn-2">[1]</a></sup> footnotes<sup class="footnote-reference" id="fr-1-1"><a href="#fn-1">[2]</a></sup></p>
6+
<hr><ol class="footnotes-list">
7+
<li id="fn-2">
8+
<p>But they are <a href="#fr-2-1">↩</a></p>
9+
</li>
10+
<li id="fn-1">
11+
<p>not sorted. <a href="#fr-1-1">↩</a></p>
12+
</li>
13+
</ol>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: components/markdown/src/markdown.rs
3+
expression: html
4+
---
5+
<p>This text has a footnote<sup class="footnote-reference" id="fr-1-1"><a href="#fn-1">[1]</a></sup></p>
6+
<hr><ol class="footnotes-list">
7+
<li id="fn-1">
8+
<p>But it is meaningless. <a href="#fr-1-1">↩</a></p>
9+
</li>
10+
</ol>

0 commit comments

Comments
 (0)