Skip to content

Commit 7071010

Browse files
committed
Implemented bottom footnotes with backreferences
Fixes #1285
1 parent 7bf429b commit 7071010

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
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

+159
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
use std::collections::HashMap;
12
use std::fmt::Write;
23

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

244+
fn fix_github_style_footnotes(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 newer fail, because Tag::FootnoteDefinition start 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+
old_events.extend(filtered_events);
297+
298+
if footnotes.is_empty() {
299+
return;
300+
}
301+
302+
old_events.push(Event::Html("<hr><ol class=\"footnotes-list\">\n".into()));
303+
304+
// Step 2: retain only footnotes which was actually referenced
305+
footnotes.retain(|f| match f.first() {
306+
Some(Event::Start(Tag::FootnoteDefinition(name))) => {
307+
footnote_numbers.get(name).unwrap_or(&(0, 0)).1 != 0
308+
}
309+
_ => false,
310+
});
311+
312+
// Step 3: Sort footnotes in the order of their appearance
313+
footnotes.sort_by_cached_key(|f| match f.first() {
314+
Some(Event::Start(Tag::FootnoteDefinition(name))) => {
315+
footnote_numbers.get(name).unwrap_or(&(0, 0)).0
316+
}
317+
_ => unreachable!(),
318+
});
319+
320+
// Step 4: Add backreferences to footnotes
321+
let footnotes = footnotes.into_iter().flat_map(|fl| {
322+
// To write backrefs, the name needs kept until the end of the footnote definition.
323+
let mut name = CowStr::from("");
324+
// Backrefs are included in the final paragraph of the footnote, if it's normal text.
325+
// For example, this DOM can be produced:
326+
//
327+
// Markdown:
328+
//
329+
// five [^feet].
330+
//
331+
// [^feet]:
332+
// A foot is defined, in this case, as 0.3048 m.
333+
//
334+
// Historically, the foot has not been defined this way, corresponding to many
335+
// subtly different units depending on the location.
336+
//
337+
// HTML:
338+
//
339+
// <p>five <sup class="footnote-reference" id="fr-feet-1"><a href="#fn-feet">[1]</a></sup>.</p>
340+
//
341+
// <ol class="footnotes-list">
342+
// <li id="fn-feet">
343+
// <p>A foot is defined, in this case, as 0.3048 m.</p>
344+
// <p>Historically, the foot has not been defined this way, corresponding to many
345+
// subtly different units depending on the location. <a href="#fr-feet-1">↩</a></p>
346+
// </li>
347+
// </ol>
348+
//
349+
// This is mostly a visual hack, so that footnotes use less vertical space.
350+
//
351+
// If there is no final paragraph, such as a tabular, list, or image footnote, it gets
352+
// pushed after the last tag instead.
353+
let mut has_written_backrefs = false;
354+
let fl_len = fl.len();
355+
let footnote_numbers = &footnote_numbers;
356+
fl.into_iter().enumerate().map(move |(i, f)| match f {
357+
Event::Start(Tag::FootnoteDefinition(current_name)) => {
358+
name = current_name;
359+
has_written_backrefs = false;
360+
Event::Html(format!(r##"<li id="fn-{name}">"##).into())
361+
}
362+
Event::End(TagEnd::FootnoteDefinition) | Event::End(TagEnd::Paragraph)
363+
if !has_written_backrefs && i >= fl_len - 2 =>
364+
{
365+
let usage_count = footnote_numbers.get(&name).unwrap().1;
366+
let mut end = String::with_capacity(
367+
name.len() + (r##" <a href="#fr--1">↩</a></li>"##.len() * usage_count),
368+
);
369+
for usage in 1..=usage_count {
370+
if usage == 1 {
371+
write!(&mut end, r##" <a href="#fr-{name}-{usage}">↩</a>"##)
372+
.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+
393+
old_events.extend(footnotes);
394+
old_events.push(Event::Html("</ol>\n".into()));
395+
}
396+
242397
pub fn markdown_to_html(
243398
content: &str,
244399
context: &RenderContext,
@@ -623,6 +778,10 @@ pub fn markdown_to_html(
623778
insert_many(&mut events, anchors_to_insert);
624779
}
625780

781+
if context.config.markdown.bottom_footnotes {
782+
fix_github_style_footnotes(&mut events);
783+
}
784+
626785
cmark::html::push_html(&mut html, events.into_iter());
627786
}
628787

0 commit comments

Comments
 (0)