From 494f2ad367a90cb026148c18e704b59ee5724a35 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 11 Jan 2025 16:53:27 -0500 Subject: [PATCH] Footnotes/sidenotes --- site_test/css/main.scss | 143 ++++++++- site_test/layout/default.html | 2 +- src/generator/markdown.rs | 10 +- src/generator/markdown/footnote_backrefs.rs | 83 ------ src/generator/markdown/footnote_defs.rs | 100 ------- src/generator/markdown/sidenotes.rs | 306 ++++++++++++++++++++ 6 files changed, 440 insertions(+), 204 deletions(-) delete mode 100644 src/generator/markdown/footnote_backrefs.rs delete mode 100644 src/generator/markdown/footnote_defs.rs create mode 100644 src/generator/markdown/sidenotes.rs diff --git a/site_test/css/main.scss b/site_test/css/main.scss index b4f4772..f4b1d17 100644 --- a/site_test/css/main.scss +++ b/site_test/css/main.scss @@ -1,25 +1,40 @@ @import "normalize.scss"; @import "fonts.scss"; +$page-horizontal-margin: 2rem; +$mobile-breakpoint: 480px; +$container-max-width: 768px; :root { --background-color: #f8e7cf; --text-color: black; + --secondary-text-color: #656565; --link-color: blue; --page-vertical-margin: 3rem; - --page-horizontal-margin: 2rem; } .container { - max-width: 768px; + max-width: $container-max-width; + margin: 0 $page-horizontal-margin / 2; +} + +@media (min-width: calc($container-max-width + 2 * $page-horizontal-margin)) { + .container { + margin: 0 $page-horizontal-margin; + } } a, a:visited { color: var(--link-color); - text-decoration-thickness: 0.05em; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + /* color: white; + text-decoration: none; + background-color: black; + padding: 0 4px; */ } a:hover { - text-decoration-thickness: 0.1em; + text-decoration-thickness: 3px; } a[href^="http://"]::after, a[href^="https://"]::after @@ -37,9 +52,23 @@ a[href^="https://"]::after pre, code { font-family: "Berkeley Mono"; + font-size: 0.9em; } -body { +blockquote { + position: relative; + font-style: italic; + + &::before { + content: open-quote; + font-size: 2em; + position: absolute; + left: -25px; + top: -10px; + } +} + +html { font-family: "Valkyrie A", Charter, serif; font-size: 16px; /* background-color: #dfd3c3; */ @@ -51,24 +80,43 @@ body { header { margin-top: var(--page-vertical-margin); font-style: italic; + h1 { font-size: 5rem; margin: 0; - text-underline-offset: 0.5rem; a { color: var(--text-color) !important; transition: text-decoration-thickness 0.1s ease-in-out; + text-decoration-thickness: 4px; + text-underline-offset: 0.5rem; + &:hover { + text-decoration-thickness: 8px; + } } } p { margin-top: 0.25rem; font-size: 1.5rem; font-weight: lighter; + text-wrap: balance; } } +@media (max-width: $mobile-breakpoint) { + header h1 { + font-size: 4rem; + } +} + +.article-title { + // balance the number of words per line + text-wrap: balance; +} + .article-content { font-size: 1.25rem; + // Chrome only, but minimizes orphan words + text-wrap: pretty; } .header-anchor { @@ -77,14 +125,84 @@ header { vertical-align: middle; // drag it up so it's more in the middle padding-bottom: 0.25rem; - color: gray !important; + color: var(--secondary-text-color) !important; + background: none; &:hover { color: var(--link-color) !important; } } -.footnote-reference > a { - text-decoration: none; +.footnote-reference { + > a { + text-decoration: none; + + &[href^="#fnref-"] { + display: none; + } + } + + &:hover + .sidenote { + color: black; + } +} + +.sidenote { + float: right; + margin-right: -50%; + width: 40%; + font-size: 1rem; + line-height: 1.25; + color: var(--secondary-text-color); + transition: color 0.2s ease-in-out; + display: none; + + .sidenote-p { + margin: 20px 0; + display: block; + } +} + +.footnote { + display: flex; + flex-direction: row; + gap: 8px; + font-size: 1rem; + + .footnote-marker { + flex-shrink: 0; + width: 34px; + text-align: right; + } + + .footnote-backref { + text-decoration: none; + } + + > div > p:first-child { + margin-top: 0; + } + > div > p:last-child { + margin-top: 0; + } +} + +// 1.5 to account for -50% margin-right on .sidenote +// plus page-horizontal-margin to effectively use that as the margin +@media (min-width: calc(1.5 * $container-max-width + 2 * $page-horizontal-margin)) { + .footnote-reference { + > a[href^="#fn-"] { + display: none; + } + > a[href^="#fnref-"] { + display: inline; + } + } + .sidenote { + display: block; + } + .footnote { + display: none; + } } footer { @@ -97,9 +215,8 @@ footer { } } -@media (min-width: calc(768px + 2rem)) { - .container { - margin-left: var(--page-horizontal-margin); - margin-right: var(--page-horizontal-margin); +@media (max-width: $mobile-breakpoint) { + footer { + font-size: 1rem; } } diff --git a/site_test/layout/default.html b/site_test/layout/default.html index db3f1c0..ed0b8f9 100644 --- a/site_test/layout/default.html +++ b/site_test/layout/default.html @@ -52,7 +52,7 @@
  • Archive
  • Contact
  • -
  • Generated on {{ _generated_at | pretty_date }} by v7.
  • +
  • Generated on {{ _generated_at | pretty_date }}, by v7.
  • diff --git a/src/generator/markdown.rs b/src/generator/markdown.rs index 37a5e3e..138b177 100644 --- a/src/generator/markdown.rs +++ b/src/generator/markdown.rs @@ -1,7 +1,6 @@ -mod footnote_backrefs; -mod footnote_defs; mod heading_anchors; mod highlight; +mod sidenotes; use pulldown_cmark::{Event, Options, Parser, html}; use std::io::Write; @@ -19,11 +18,8 @@ pub fn parse<'a>(s: &'a str) -> impl Iterator> { let parser = Parser::new_ext(s, options); // TODO: revisit which of these stages are necessary, remove unused (and url crate dep) let heading_anchors = heading_anchors::new(parser); - // note backrefs need to come before defs, because the defs stage replaces the - // Tag::FootnoteDefinition events that the backrefs stage relies on with plain html - let footnote_backrefs = footnote_backrefs::new(heading_anchors); - let footnote_defs = footnote_defs::new(footnote_backrefs); - let highlight = highlight::new(footnote_defs); + let sidenotes = sidenotes::new(heading_anchors); + let highlight = highlight::new(sidenotes); highlight } diff --git a/src/generator/markdown/footnote_backrefs.rs b/src/generator/markdown/footnote_backrefs.rs deleted file mode 100644 index d4f43ba..0000000 --- a/src/generator/markdown/footnote_backrefs.rs +++ /dev/null @@ -1,83 +0,0 @@ -use pulldown_cmark::{CowStr, Event, Tag, TagEnd}; -use std::iter::Peekable; - -pub struct FootnoteBackrefs<'a, I: Iterator>> { - iter: Peekable, - next: Option>, - last_footnote_definition_start: Option>, -} - -pub fn new<'a, I: Iterator>>(iter: I) -> FootnoteBackrefs<'a, I> { - FootnoteBackrefs { - iter: iter.peekable(), - next: None, - last_footnote_definition_start: None, - } -} - -impl<'a, I: Iterator>> Iterator for FootnoteBackrefs<'a, I> { - type Item = Event<'a>; - - fn next(&mut self) -> Option { - if let Some(e) = self.next.take() { - return Some(e); - } - - match self.iter.next() { - Some(Event::FootnoteReference(label)) => { - let html = format!( - r##"[{}]"##, - label, label, label - ); - Some(Event::Html(CowStr::Boxed(html.into_boxed_str()))) - } - Some(Event::Start(Tag::FootnoteDefinition(label))) => { - self.last_footnote_definition_start = Some(label.clone()); - Some(Event::Start(Tag::FootnoteDefinition(label))) - } - Some(Event::End(TagEnd::Paragraph)) => { - if let Some(Event::End(TagEnd::FootnoteDefinition)) = self.iter.peek() { - assert!(self.next.is_none()); - self.next = Some(Event::End(TagEnd::Paragraph)); - let label = self - .last_footnote_definition_start - .take() - .expect("footnote definition must have started before ending"); - let html = format!( - r##" "##, - label - ); - Some(Event::Html(CowStr::Boxed(html.into_boxed_str()))) - } else { - Some(Event::End(TagEnd::Paragraph)) - } - } - e => e, - } - } -} - -#[cfg(test)] -mod tests { - use pulldown_cmark::{Options, Parser, html}; - - fn render(s: &str) -> String { - let mut out = String::new(); - let parser = Parser::new_ext(s, Options::ENABLE_FOOTNOTES); - let footnote_backrefs = super::new(parser); - html::push_html(&mut out, footnote_backrefs); - out - } - - #[test] - fn test_footnote_backrefs() { - assert_eq!( - render("foo[^1]\n\n[^1]: bar"), - r##"

    foo[1]

    -
    1 -

    bar

    -
    -"## - ); - } -} diff --git a/src/generator/markdown/footnote_defs.rs b/src/generator/markdown/footnote_defs.rs deleted file mode 100644 index 5cff2d5..0000000 --- a/src/generator/markdown/footnote_defs.rs +++ /dev/null @@ -1,100 +0,0 @@ -use pulldown_cmark::{CowStr, Event, Tag, TagEnd}; -use std::collections::VecDeque; - -pub fn new<'a, I: Iterator>>(iter: I) -> FootnoteDefs<'a, I> { - FootnoteDefs { - iter, - footnote_events: VecDeque::new(), - has_started_emitting_defs: false, - } -} - -pub struct FootnoteDefs<'a, I: Iterator>> { - iter: I, - footnote_events: VecDeque>, - has_started_emitting_defs: bool, -} - -impl<'a, I: Iterator>> Iterator for FootnoteDefs<'a, I> { - type Item = Event<'a>; - - fn next(&mut self) -> Option { - match self.iter.next() { - Some(Event::Start(Tag::FootnoteDefinition(ref id))) => { - self.footnote_events.push_back(Event::Html( - format!( - r#"
    {}."#, - id, id - ) - .into(), - )); - - loop { - match self.iter.next() { - Some(Event::End(TagEnd::FootnoteDefinition)) => { - self.footnote_events.push_back(Event::Html("
    ".into())); - break; - } - Some(e) => { - self.footnote_events.push_back(e); - } - None => { - break; - } - } - } - self.next() - } - Some(e) => Some(e), - None => { - if !self.has_started_emitting_defs && !self.footnote_events.is_empty() { - self.has_started_emitting_defs = true; - let before: CowStr<'a> = - r#"
    "#.into(); - self.footnote_events.push_front(Event::Html(before)); - let after: CowStr<'a> = "
    ".into(); - self.footnote_events.push_back(Event::Html(after)); - } - self.footnote_events.pop_front() - } - } - } -} - -#[cfg(test)] -mod tests { - use pulldown_cmark::{Options, Parser, html}; - - fn render(s: &str) -> String { - let mut out = String::new(); - let parser = Parser::new_ext(s, Options::ENABLE_FOOTNOTES); - let footnote_backrefs = super::new(parser); - html::push_html(&mut out, footnote_backrefs); - out - } - - #[test] - fn test_group_footnotes() { - assert_eq!( - render("foo[^1]\n\n[^1]: bar\n\nbaz"), - r##"

    foo1

    -

    baz

    -
    1. -

    bar

    -
    "## - ); - } - - #[test] - fn test_multiple() { - assert_eq!( - render("foo[^1] bar[^2]\n\n[^1]: foo\n\n[^2]: bar"), - r##"

    foo1 bar2

    -
    1. -

    foo

    -
    2. -

    bar

    -
    "## - ); - } -} diff --git a/src/generator/markdown/sidenotes.rs b/src/generator/markdown/sidenotes.rs new file mode 100644 index 0000000..d9c0da7 --- /dev/null +++ b/src/generator/markdown/sidenotes.rs @@ -0,0 +1,306 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use pulldown_cmark::{CowStr, Event, Tag, TagEnd}; + +pub fn new<'a>(iter: impl Iterator>) -> impl Iterator> { + let adjoined = AdjoinFootnoteDefinitions::new(iter); + let containers = InsertSidenoteContainers::new(adjoined); + // InsertFootnoteDefnMarkers::new(containers) + containers +} + +struct AdjoinFootnoteDefinitions<'a> { + state: AdjoinState<'a>, + all_events: std::vec::IntoIter>, + definition_order: VecDeque>, + definitions: HashMap, Vec>>, +} + +enum AdjoinState<'a> { + Idle, + EmittingDefinition(std::vec::IntoIter>), +} + +impl<'a> AdjoinFootnoteDefinitions<'a> { + fn new(mut inner: impl Iterator>) -> Self { + let mut all_events = vec![]; + let mut definition_order = VecDeque::new(); + let mut definitions = HashMap::new(); + while let Some(event) = inner.next() { + match event { + Event::Start(Tag::FootnoteDefinition(id)) => { + definition_order.push_back(id.clone()); + let mut defn_events = vec![Event::Start(Tag::FootnoteDefinition(id.clone()))]; + 'defn: while let Some(event) = inner.next() { + match event { + Event::End(TagEnd::FootnoteDefinition) => { + defn_events.push(Event::End(TagEnd::FootnoteDefinition)); + break 'defn; + } + _ => defn_events.push(event), + } + } + definitions.insert(id, defn_events); + } + _ => all_events.push(event), + } + } + Self { + state: AdjoinState::Idle, + all_events: all_events.into_iter(), + definition_order, + definitions, + } + } +} + +impl<'a> Iterator for AdjoinFootnoteDefinitions<'a> { + type Item = Event<'a>; + + fn next(&mut self) -> Option { + match &mut self.state { + AdjoinState::Idle => match self.all_events.next() { + Some(Event::FootnoteReference(id)) => { + if let Some(defn) = self.definitions.get(&id) { + self.state = AdjoinState::EmittingDefinition(defn.clone().into_iter()); + } + Some(Event::FootnoteReference(id)) + } + Some(e) => Some(e), + None => match self.definition_order.pop_front() { + Some(id) => { + if let Some(defn) = self.definitions.get(&id) { + self.state = AdjoinState::EmittingDefinition(defn.clone().into_iter()); + } + self.next() + } + None => None, + }, + }, + AdjoinState::EmittingDefinition(defn) => match defn.next() { + Some(e) => Some(e), + None => { + self.state = AdjoinState::Idle; + self.next() + } + }, + } + } +} + +struct InsertSidenoteContainers<'a, I: Iterator>> { + inner: I, + state: ReplaceState<'a>, + seen_references: HashSet>, +} + +#[derive(Clone)] +enum ReplaceState<'a> { + WaitingForReference, + SawReference(CowStr<'a>), + InsideSidenote, + InsideFootnote(CowStr<'a>), + EmitNext(Event<'a>, Box>), +} + +impl<'a, I: Iterator>> InsertSidenoteContainers<'a, I> { + fn new(inner: I) -> Self { + Self { + inner, + state: ReplaceState::WaitingForReference, + seen_references: HashSet::new(), + } + } +} + +impl<'a, I: Iterator>> Iterator for InsertSidenoteContainers<'a, I> { + type Item = Event<'a>; + + fn next(&mut self) -> Option { + use ReplaceState::*; + + match self.state { + WaitingForReference => match self.inner.next() { + Some(Event::FootnoteReference(id)) => { + assert!(!self.seen_references.contains(&id)); + self.state = SawReference(id.clone()); + Some(Event::Html(format!(r##"[{id}][{id}]"##).into())) + } + Some(Event::Start(Tag::FootnoteDefinition(id))) => { + self.state = InsideFootnote(id.clone()); + Some(Event::Html( + format!( + r#"
    {id}.
    "#, + ) + .into(), + )) + } + maybe_event => maybe_event, + }, + SawReference(ref id) => match self.inner.next() { + Some(Event::Start(Tag::FootnoteDefinition(def_id))) => { + assert_eq!(id, &def_id); + self.seen_references.insert(def_id.clone()); + self.state = InsideSidenote; + Some(Event::Html(format!( + r#"{def_id}. "#, + ).into())) + } + e => panic!("should not receive {e:?} while in SawReference state"), + }, + InsideSidenote => match self.inner.next() { + // We drop

    in sidenote defn because, since the sidenote defn appears inside of a normal paragraph, + // the sidenote defn

    would implicitly close the containing

    and screw up the hierarchy. + Some(Event::Start(Tag::Paragraph)) => Some(Event::Html(CowStr::Borrowed(""))), + Some(Event::End(TagEnd::Paragraph)) => Some(Event::Html(CowStr::Borrowed(""))), + Some(Event::End(TagEnd::FootnoteDefinition)) => { + self.state = WaitingForReference; + Some(Event::Html(CowStr::Borrowed(""))) + } + e => e, + }, + InsideFootnote(ref id) => match self.inner.next() { + Some(Event::End(TagEnd::Paragraph)) => { + // unicode variation selector to make the back arrow always render as text (not emoji) + let text_selector = "\u{FE0E}"; + let event = Event::Html( + format!( + r##" ↩{text_selector}"## + ) + .into(), + ); + let close_divs = EmitNext( + Event::Html(CowStr::Borrowed("

    ")), + Box::new(InsideFootnote(id.clone())), + ); + let end_para = EmitNext(Event::End(TagEnd::Paragraph), Box::new(close_divs)); + self.state = end_para; + Some(event) + } + Some(Event::End(TagEnd::FootnoteDefinition)) => { + self.state = WaitingForReference; + self.next() + } + maybe_event => maybe_event, + }, + EmitNext(ref event, ref next_state) => { + let event = event.clone(); + self.state = next_state.as_ref().clone(); + Some(event) + } + } + } +} + +#[cfg(test)] +mod tests { + use pulldown_cmark::{CowStr, Event, Event::*, Options, Parser, Tag, TagEnd}; + + fn render_adjoined(s: &'static str) -> Vec> { + let mut options = Options::empty(); + options.insert(Options::ENABLE_FOOTNOTES); + super::AdjoinFootnoteDefinitions::new(Parser::new_ext(s, options)).collect() + } + + fn render_sidenote_containers(s: &'static str) -> Vec> { + let mut options = Options::empty(); + options.insert(Options::ENABLE_FOOTNOTES); + super::InsertSidenoteContainers::new(super::AdjoinFootnoteDefinitions::new( + Parser::new_ext(s, options), + )) + .collect() + } + + fn render(s: &'static str) -> Vec> { + let mut options = Options::empty(); + options.insert(Options::ENABLE_FOOTNOTES); + super::new(Parser::new_ext(s, options)).collect() + } + + #[test] + fn adjoin_simple() { + assert_eq!(render_adjoined("[^1]\n\n[^1]: defn\n\nbar"), vec![ + Start(Tag::Paragraph), + FootnoteReference(CowStr::Borrowed("1")), + Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("defn")), + End(TagEnd::Paragraph), + End(TagEnd::FootnoteDefinition), + End(TagEnd::Paragraph), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("bar")), + End(TagEnd::Paragraph), + Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("defn")), + End(TagEnd::Paragraph), + End(TagEnd::FootnoteDefinition) + ]) + } + + #[test] + fn adjoin_out_of_order() { + assert_eq!( + render_adjoined("[^1]\n\n[^2]\n\n[^2]: baz\n\n[^1]: defn\n\nbar"), + vec![ + Start(Tag::Paragraph), + FootnoteReference(CowStr::Borrowed("1")), + Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("defn")), + End(TagEnd::Paragraph), + End(TagEnd::FootnoteDefinition), + End(TagEnd::Paragraph), + Start(Tag::Paragraph), + FootnoteReference(CowStr::Borrowed("2")), + Start(Tag::FootnoteDefinition(CowStr::Borrowed("2"))), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("baz")), + End(TagEnd::Paragraph), + End(TagEnd::FootnoteDefinition), + End(TagEnd::Paragraph), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("bar")), + End(TagEnd::Paragraph), + Start(Tag::FootnoteDefinition(CowStr::Borrowed("2"))), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("baz")), + End(TagEnd::Paragraph), + End(TagEnd::FootnoteDefinition), + Start(Tag::FootnoteDefinition(CowStr::Borrowed("1"))), + Start(Tag::Paragraph), + Text(CowStr::Borrowed("defn")), + End(TagEnd::Paragraph), + End(TagEnd::FootnoteDefinition), + ] + ) + } + + #[test] + fn sidenote_markers() { + assert_eq!(render("look[^1]\n\n[^1]: blah\n\nweee"), vec![ + Start(Tag::Paragraph), + Text("look".into()), + Html(r##"[1][1]"##.into()), + Html(r#"1. "#.into()), + Html("".into()), + Text("blah".into()), + Html("".into()), + Html("".into()), + End(TagEnd::Paragraph), + Start(Tag::Paragraph), + Text("weee".into()), + End(TagEnd::Paragraph), + Html( + r#"
    1.
    "# + .into() + ), + Start(Tag::Paragraph), + Text("blah".into()), + Html(r##" "##.into()), + End(TagEnd::Paragraph), + Html("
    ".into()), + ]) + } +}